pax_global_header00006660000000000000000000000064143371105600014512gustar00rootroot0000000000000052 comment=95e47ccff04c3bc22afdc1e1273b7e7eba1a8905 caldav-0.11.0/000077500000000000000000000000001433711056000130235ustar00rootroot00000000000000caldav-0.11.0/.github/000077500000000000000000000000001433711056000143635ustar00rootroot00000000000000caldav-0.11.0/.github/workflows/000077500000000000000000000000001433711056000164205ustar00rootroot00000000000000caldav-0.11.0/.github/workflows/tests.yaml000066400000000000000000000030201433711056000204410ustar00rootroot00000000000000--- name: tests on: push: branches: - master pull_request: branches: - master jobs: tests: name: ${{ matrix.python }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: python: - '3.10' - '3.9' - '3.8' - '3.7' steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: python-version: ${{ matrix.python }} - uses: actions/cache@v1 with: path: ~/.cache/pip key: pip|${{ hashFiles('setup.py') }}|${{ hashFiles('tox.ini') }} - run: pip install tox - run: tox -e py docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: python-version: '3.10' - uses: actions/cache@v1 with: path: ~/.cache/pip key: pip|${{ hashFiles('setup.py') }}|${{ hashFiles('tox.ini') }} - run: pip install tox - run: tox -e docs style: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: python-version: '3.10' - uses: actions/cache@v1 with: path: ~/.cache/pip key: pip|${{ hashFiles('setup.py') }}|${{ hashFiles('tox.ini') }} - uses: actions/cache@v1 with: path: ~/.cache/pre-commit key: pre-commit|${{ hashFiles('.pre-commit-config.yaml') }} - run: pip install tox - run: tox -e style caldav-0.11.0/.gitignore000066400000000000000000000004021433711056000150070ustar00rootroot00000000000000.coverage *.pyc .hg* *.swp .DS_Store parts downloads eggs develop-eggs bin build docs/build dist include .Python .*.egg-info .installed.cfg accountific.db .coverage .noseids tests/.noseids *.bak *~ #*# caldav.egg-info/ tests/conf_private.py .tox .eggs .venv caldav-0.11.0/.pre-commit-config.yaml000066400000000000000000000007031433711056000173040ustar00rootroot00000000000000--- repos: - repo: https://github.com/asottile/reorder_python_imports rev: v3.1.0 hooks: - id: reorder-python-imports args: ["--application-directories", "src"] - repo: https://github.com/psf/black rev: 22.6.0 hooks: - id: black - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.3.0 hooks: - id: check-byte-order-marker - id: trailing-whitespace - id: end-of-file-fixer caldav-0.11.0/COPYING.APACHE000066400000000000000000000261361433711056000150060ustar00rootroot00000000000000 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. caldav-0.11.0/COPYING.GPL000066400000000000000000001045131433711056000145030ustar00rootroot00000000000000 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 . caldav-0.11.0/MANIFEST.in000066400000000000000000000001271433711056000145610ustar00rootroot00000000000000include COPYING.* include changelog.0.8.md include README.md recursive-include tests * caldav-0.11.0/Makefile000066400000000000000000000006101433711056000144600ustar00rootroot00000000000000TOX_DIR = .tox BIN_DIR = ${TOX_DIR}/py37/bin/ install: tox --recreate --notest test: tox package: $(BIN_DIR)pip install wheel $(BIN_DIR)python setup.py sdist bdist_wheel doc: $(BIN_DIR)pip install sphinx $(BIN_DIR)python setup.py build_sphinx clean: find . -name __pycache__ -exec rm -r {} + rm -rf caldav.egg-info dist docs/build ${TOX_DIR} mrproper: clean rm -rf ${TOX_DIR} caldav-0.11.0/README.md000066400000000000000000000011731433711056000143040ustar00rootroot00000000000000# caldav This project is a CalDAV ([RFC4791](http://www.ietf.org/rfc/rfc4791.txt)) client library for Python. Features: * create, modify calendar * create, update and delete event * search events by dates * etc. See the file [examples/basic_usage_examples.py](examples/basic_usage_examples.py) to get started. Links: * [Pypi](https://pypi.org/project/caldav) * [Documentation](docs/source/index.rst) - should be automatically mirrored on https://caldav.readthedocs.io/en/latest/ Licences: Caldav is dual-licensed under the [GNU GENERAL PUBLIC LICENSE Version 3](COPYING.GPL) and the [Apache License 2.0](COPYING.APACHE). caldav-0.11.0/caldav/000077500000000000000000000000001433711056000142555ustar00rootroot00000000000000caldav-0.11.0/caldav/__init__.py000066400000000000000000000010231433711056000163620ustar00rootroot00000000000000#!/usr/bin/env python import logging import vobject.icalendar from .davclient import DAVClient from .objects import * ## Notes: ## ## * The vobject.icalendar has (or had?) to be explicitly imported due to some bug in the tBaxter fork of vobject. ## * The "import *" looks quite ugly, should be revisited prior to launching 1.0. # Silence notification of no default logging handler log = logging.getLogger("caldav") class NullHandler(logging.Handler): def emit(self, record): pass log.addHandler(NullHandler()) caldav-0.11.0/caldav/davclient.py000066400000000000000000000575721433711056000166200ustar00rootroot00000000000000#!/usr/bin/env python # -*- encoding: utf-8 -*- import logging import re import requests import six from caldav.elements import cdav from caldav.elements import dav from caldav.elements import ical from caldav.lib import error from caldav.lib.python_utilities import to_normal_str from caldav.lib.python_utilities import to_unicode from caldav.lib.python_utilities import to_wire from caldav.lib.url import URL from caldav.objects import Calendar from caldav.objects import errmsg from caldav.objects import log from caldav.objects import Principal from caldav.objects import ScheduleInbox from caldav.objects import ScheduleOutbox from lxml import etree if six.PY3: from urllib.parse import unquote else: from urlparse import unquote class DAVResponse: """ This class is a response from a DAV request. It is instantiated from the DAVClient class. End users of the library should not need to know anything about this class. Since we often get XML responses, it tries to parse it into `self.tree` """ raw = "" reason = "" tree = None headers = {} status = 0 def __init__(self, response): self.headers = response.headers log.debug("response headers: " + str(self.headers)) log.debug("response status: " + str(self.status)) self._raw = response.content ## TODO: this if/else/elif could possibly be refactored, or we should ## consider to do streaming into the xmltree library as originally ## intended. It only makes sense for really huge payloads though. if self.headers.get("Content-Type", "").startswith( "text/xml" ) or self.headers.get("Content-Type", "").startswith("application/xml"): try: content_length = int(self.headers["Content-Length"]) except: content_length = -1 if content_length == 0 or not self._raw: self._raw = "" self.tree = None log.debug("No content delivered") else: ## With response.raw we could be streaming the content, but it does not work because ## the stream often is compressed. We could add uncompression on the fly, but not ## considered worth the effort as for now. # self.tree = etree.parse(response.raw, parser=etree.XMLParser(remove_blank_text=True)) try: self.tree = etree.XML( self._raw, parser=etree.XMLParser(remove_blank_text=True) ) except: logging.critical( "Expected some valid XML from the server, but got this: \n" + self._raw, exc_info=True, ) raise if log.level <= logging.DEBUG: log.debug(etree.tostring(self.tree, pretty_print=True)) elif self.headers.get("Content-Type", "").startswith( "text/calendar" ) or self.headers.get("Content-Type", "").startswith("text/plain"): ## text/plain is typically for errors, we shouldn't see it on 200/207 responses. ## TODO: may want to log an error if it's text/plain and 200/207. ## Logic here was moved when refactoring pass else: ## Probably no content type given (iCloud). Some servers ## give text/html as the default when no content is ## delivered or on errors (ref ## https://github.com/python-caldav/caldav/issues/142). ## TODO: maybe just remove all of the code above in this if/else and let all ## data be parsed through this code. try: self.tree = etree.XML( self._raw, parser=etree.XMLParser(remove_blank_text=True) ) except: pass ## this if will always be true as for now, see other comments on streaming. if hasattr(self, "_raw"): log.debug(self._raw) # ref https://github.com/python-caldav/caldav/issues/112 stray CRs may cause problems if type(self._raw) == bytes: self._raw = self._raw.replace(b"\r\n", b"\n") elif type(self._raw) == str: self._raw = self._raw.replace("\r\n", "\n") self.status = response.status_code ## ref https://github.com/python-caldav/caldav/issues/81, ## incidents with a response without a reason has been ## observed try: self.reason = response.reason except AttributeError: self.reason = "" @property def raw(self): ## TODO: this should not really be needed? if not hasattr(self, "_raw"): self._raw = etree.tostring(self.tree, pretty_print=True) return self._raw def _strip_to_multistatus(self): """ The general format of inbound data is something like this: (...) (...) (...) but sometimes the multistatus and/or xml element is missing in self.tree. We don't want to bother with the multistatus and xml tags, we just want the response list. An "Element" in the lxml library is a list-like object, so we should typically return the element right above the responses. If there is nothing but a response, return it as a list with one element. (The equivalent of this method could probably be found with a simple XPath query, but I'm not much into XPath) """ tree = self.tree if tree.tag == "xml" and tree[0].tag == dav.MultiStatus.tag: return tree[0] if tree.tag == dav.MultiStatus.tag: return self.tree return [self.tree] def validate_status(self, status): """ status is a string like "HTTP/1.1 404 Not Found". 200, 207 and 404 are considered good statuses. The SOGo caldav server even returns "201 created" when doing a sync-report, to indicate that a resource was created after the last sync-token. This makes sense to me, but I've only seen it from SOGo, and it's not in accordance with the examples in rfc6578. """ if ( " 200 " not in status and " 201 " not in status and " 207 " not in status and " 404 " not in status ): raise error.ResponseError(status) def _parse_response(self, response): """ One response should contain one or zero status children, one href tag and zero or more propstats. Find them, assert there isn't more in the response and return those three fields """ status = None href = None propstats = [] error.assert_(response.tag == dav.Response.tag) for elem in response: if elem.tag == dav.Status.tag: error.assert_(not status) status = elem.text error.assert_(status) self.validate_status(status) elif elem.tag == dav.Href.tag: assert not href href = unquote(elem.text) elif elem.tag == dav.PropStat.tag: propstats.append(elem) else: error.assert_(False) error.assert_(href) return (href, propstats, status) def find_objects_and_props(self): """Check the response from the server, check that it is on an expected format, find hrefs and props from it and check statuses delivered. The parsed data will be put into self.objects, a dict {href: {proptag: prop_element}}. Further parsing of the prop_element has to be done by the caller. self.sync_token will be populated if found, self.objects will be populated. """ self.objects = {} if "Schedule-Tag" in self.headers: self.schedule_tag = self.headers["Schedule-Tag"] responses = self._strip_to_multistatus() for r in responses: if r.tag == dav.SyncToken.tag: self.sync_token = r.text continue error.assert_(r.tag == dav.Response.tag) (href, propstats, status) = self._parse_response(r) ## I would like to do this assert here ... # error.assert_(not href in self.objects) ## but then there was https://github.com/python-caldav/caldav/issues/136 if not href in self.objects: self.objects[href] = {} ## The properties may be delivered either in one ## propstat with multiple props or in multiple ## propstat for propstat in propstats: cnt = 0 status = propstat.find(dav.Status.tag) error.assert_(status is not None) if status is not None: error.assert_(len(status) == 0) cnt += 1 self.validate_status(status.text) ## if a prop was not found, ignore it if " 404 " in status.text: continue for prop in propstat.iterfind(dav.Prop.tag): cnt += 1 for theprop in prop: self.objects[href][theprop.tag] = theprop ## there shouldn't be any more elements except for status and prop error.assert_(cnt == len(propstat)) return self.objects def _expand_simple_prop( self, proptag, props_found, multi_value_allowed=False, xpath=None ): values = [] if proptag in props_found: prop_xml = props_found[proptag] error.assert_(not prop_xml.items()) if not xpath and len(prop_xml) == 0: if prop_xml.text: values.append(prop_xml.text) else: _xpath = xpath if xpath else ".//*" leafs = prop_xml.findall(_xpath) values = [] for leaf in leafs: error.assert_(not leaf.items()) if leaf.text: values.append(leaf.text) else: values.append(leaf.tag) if multi_value_allowed: return values else: if not values: return None error.assert_(len(values) == 1) return values[0] ## TODO: "expand" does not feel quite right. def expand_simple_props(self, props=[], multi_value_props=[], xpath=None): """ The find_objects_and_props() will stop at the xml element below the prop tag. This method will expand those props into text. Executes find_objects_and_props if not run already, then modifies and returns self.objects. """ if not hasattr(self, "objects"): self.find_objects_and_props() for href in self.objects: props_found = self.objects[href] for prop in props: props_found[prop.tag] = self._expand_simple_prop( prop.tag, props_found, xpath=xpath ) for prop in multi_value_props: props_found[prop.tag] = self._expand_simple_prop( prop.tag, props_found, xpath=xpath, multi_value_allowed=True ) return self.objects class DAVClient: """ Basic client for webdav, uses the requests lib; gives access to low-level operations towards the caldav server. Unless you have special needs, you should probably care most about the constructor (__init__), the principal method and the calendar method. """ proxy = None url = None def __init__( self, url, proxy=None, username=None, password=None, auth=None, timeout=None, ssl_verify_cert=True, ssl_cert=None, ): """ Sets up a HTTPConnection object towards the server in the url. Parameters: * url: A fully qualified url: `scheme://user:pass@hostname:port` * proxy: A string defining a proxy server: `hostname:port` * username and password should be passed as arguments or in the URL * auth, timeout and ssl_verify_cert are passed to requests.request. ** ssl_verify_cert can be the path of a CA-bundle or False. The requests library will honor a .netrc-file, if such a file exists username and password may be omitted. Known bug: .netrc is honored even if a username/password is given, ref https://github.com/python-caldav/caldav/issues/206 """ self.session = requests.Session() log.debug("url: " + str(url)) self.url = URL.objectify(url) # Prepare proxy info if proxy is not None: self.proxy = proxy # requests library expects the proxy url to have a scheme if re.match("^.*://", proxy) is None: self.proxy = self.url.scheme + "://" + proxy # add a port is one is not specified # TODO: this will break if using basic auth and embedding # username:password in the proxy URL p = self.proxy.split(":") if len(p) == 2: self.proxy += ":8080" log.debug("init - proxy: %s" % (self.proxy)) # Build global headers self.headers = { "User-Agent": "Mozilla/5.0", "Content-Type": "text/xml", "Accept": "text/xml, text/calendar", } if self.url.username is not None: username = unquote(self.url.username) password = unquote(self.url.password) self.username = username self.password = password ## I had problems with passwords with non-ascii letters in it ... if hasattr(self.password, "encode"): self.password = self.password.encode("utf-8") self.auth = auth # TODO: it's possible to force through a specific auth method here, # but no test code for this. self.timeout = timeout self.ssl_verify_cert = ssl_verify_cert self.ssl_cert = ssl_cert self.url = self.url.unauth() log.debug("self.url: " + str(url)) self._principal = None def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): self.close() def close(self): """ Closes the DAVClient's session object """ self.session.close() def principal(self, *largs, **kwargs): """ Convenience method, it gives a bit more object-oriented feel to write client.principal() than Principal(client). This method returns a :class:`caldav.Principal` object, with higher-level methods for dealing with the principals calendars. """ if not self._principal: self._principal = Principal(client=self, *largs, **kwargs) return self._principal def calendar(self, **kwargs): """Returns a calendar object. Typically, an URL should be given as a named parameter (url) No network traffic will be initiated by this method. If you don't know the URL of the calendar, use client.principal().calendar(...) instead, or client.principal().calendars() """ return Calendar(client=self, **kwargs) def check_dav_support(self): try: ## SOGo does not return the full capability list on the caldav ## root URL, and that's OK according to the RFC ... so apparently ## we need to do an extra step here to fetch the URL of some ## element that should come with caldav extras. ## Anyway, packing this into a try-except in case it fails. response = self.options(self.principal().url) except: response = self.options(self.url) return response.headers.get("DAV", None) def check_cdav_support(self): support_list = self.check_dav_support() return support_list and "calendar-access" in support_list def check_scheduling_support(self): support_list = self.check_dav_support() return support_list and "calendar-auto-schedule" in support_list def propfind(self, url=None, props="", depth=0): """ Send a propfind request. Parameters: * url: url for the root of the propfind. * props = (xml request), properties we want * depth: maximum recursion depth Returns * DAVResponse """ return self.request(url or self.url, "PROPFIND", props, {"Depth": str(depth)}) def proppatch(self, url, body, dummy=None): """ Send a proppatch request. Parameters: * url: url for the root of the propfind. * body: XML propertyupdate request * dummy: compatibility parameter Returns * DAVResponse """ return self.request(url, "PROPPATCH", body) def report(self, url, query="", depth=0): """ Send a report request. Parameters: * url: url for the root of the propfind. * query: XML request * depth: maximum recursion depth Returns * DAVResponse """ return self.request( url, "REPORT", query, {"Depth": str(depth), "Content-Type": 'application/xml; charset="utf-8"'}, ) def mkcol(self, url, body, dummy=None): """ Send a MKCOL request. MKCOL is basically not used with caldav, one should use MKCALENDAR instead. However, some calendar servers MAY allow "subcollections" to be made in a calendar, by using the MKCOL query. As for 2020-05, this method is not exercised by test code or referenced anywhere else in the caldav library, it's included just for the sake of completeness. And, perhaps this DAVClient class can be used for vCards and other WebDAV purposes. Parameters: * url: url for the root of the mkcol * body: XML request * dummy: compatibility parameter Returns * DAVResponse """ return self.request(url, "MKCOL", body) def mkcalendar(self, url, body="", dummy=None): """ Send a mkcalendar request. Parameters: * url: url for the root of the mkcalendar * body: XML request * dummy: compatibility parameter Returns * DAVResponse """ return self.request(url, "MKCALENDAR", body) def put(self, url, body, headers={}): """ Send a put request. """ return self.request(url, "PUT", body, headers) def post(self, url, body, headers={}): """ Send a POST request. """ return self.request(url, "POST", body, headers) def delete(self, url): """ Send a delete request. """ return self.request(url, "DELETE") def options(self, url): return self.request(url, "OPTIONS") def extract_auth_types(self, header): auth_types = header.lower().split(",") auth_types = map(lambda auth_type: auth_type.strip(), auth_types) auth_types = map(lambda auth_type: auth_type[: auth_type.find(" ")], auth_types) return list(filter(lambda auth_type: auth_type, auth_types)) def request(self, url, method="GET", body="", headers={}): """ Actually sends the request """ combined_headers = self.headers.copy() combined_headers.update(headers) if (body is None or body == "") and "Content-Type" in combined_headers: del combined_headers["Content-Type"] proxies = None if self.proxy is not None: proxies = {url.scheme: self.proxy} log.debug("using proxy - %s" % (proxies)) # objectify the url url = URL.objectify(url) log.debug( "sending request - method={0}, url={1}, headers={2}\nbody:\n{3}".format( method, str(url), combined_headers, to_normal_str(body) ) ) try: r = self.session.request( method, str(url), data=to_wire(body), headers=combined_headers, proxies=proxies, auth=self.auth, timeout=self.timeout, verify=self.ssl_verify_cert, cert=self.ssl_cert, ) log.debug("server responded with %i %s" % (r.status_code, r.reason)) response = DAVResponse(r) except: ## this is a workaround needed due to some weird server ## that would just abort the connection rather than send a ## 401 when an unauthenticated request with a body was ## sent to the server - ref https://github.com/python-caldav/caldav/issues/158 if self.auth or not self.password: raise r = self.session.request( method="GET", url=str(url), headers=combined_headers, proxies=proxies, timeout=self.timeout, verify=self.ssl_verify_cert, cert=self.ssl_cert, ) if not r.status_code == 401: raise if ( r.status_code == 401 and "WWW-Authenticate" in r.headers and self.password and self.username and not self.auth ): auth_types = self.extract_auth_types(r.headers["WWW-Authenticate"]) if "basic" in auth_types: self.auth = requests.auth.HTTPBasicAuth(self.username, self.password) elif "digest" in auth_types: self.auth = requests.auth.HTTPDigestAuth(self.username, self.password) else: raise NotImplementedError( "The server does not provide any of the currently " "supported authentication methods: basic, digest" ) return self.request(url, method, body, headers) elif ( r.status_code == 401 and "WWW-Authenticate" in r.headers and self.password and self.username and self.auth ): ## Some (ancient) servers don't like UTF-8 binary auth with Digest authentication. ## An example are old SabreDAV based servers. Not sure about UTF-8 and Basic Auth, ## but likely the same. so retry if password is a bytes sequence and not a string ## (see commit 13a4714, which introduced this regression) auth_types = self.extract_auth_types(r.headers["WWW-Authenticate"]) if hasattr(self.password, "decode"): if "basic" in auth_types: self.auth = requests.auth.HTTPBasicAuth( self.username, self.password.decode() ) elif "digest" in auth_types: self.auth = requests.auth.HTTPDigestAuth( self.username, self.password.decode() ) self.username = None self.password = None return self.request(url, method, body, headers) # this is an error condition that should be raised to the application if ( response.status == requests.codes.forbidden or response.status == requests.codes.unauthorized ): try: reason = response.reason except AttributeError: reason = "None given" raise error.AuthorizationError(url=str(url), reason=reason) return response caldav-0.11.0/caldav/elements/000077500000000000000000000000001433711056000160715ustar00rootroot00000000000000caldav-0.11.0/caldav/elements/__init__.py000066400000000000000000000000601433711056000201760ustar00rootroot00000000000000#!/usr/bin/env python # -*- encoding: utf-8 -*- caldav-0.11.0/caldav/elements/base.py000066400000000000000000000037251433711056000173640ustar00rootroot00000000000000#!/usr/bin/env python # -*- encoding: utf-8 -*- from caldav.lib.namespace import nsmap from caldav.lib.python_utilities import to_unicode from lxml import etree from six import PY3 class BaseElement(object): children = None tag = None value = None attributes = None caldav_class = None def __init__(self, name=None, value=None): self.children = [] self.attributes = {} value = to_unicode(value) self.value = None if name is not None: self.attributes["name"] = name if value is not None: self.value = value def __add__(self, other): return self.append(other) def __str__(self): utf8 = etree.tostring( self.xmlelement(), encoding="utf-8", xml_declaration=True, pretty_print=True ) if PY3: return str(utf8, "utf-8") return utf8 def xmlelement(self): root = etree.Element(self.tag, nsmap=nsmap) if self.value is not None: root.text = self.value if len(self.attributes) > 0: for k in list(self.attributes.keys()): root.set(k, self.attributes[k]) self.xmlchildren(root) return root def xmlchildren(self, root): for c in self.children: root.append(c.xmlelement()) def append(self, element): try: iter(element) self.children.extend(element) except TypeError: self.children.append(element) return self class NamedBaseElement(BaseElement): def __init__(self, name=None): super(NamedBaseElement, self).__init__(name=name) def xmlelement(self): if self.attributes.get("name") is None: raise Exception("name attribute must be defined") return super(NamedBaseElement, self).xmlelement() class ValuedBaseElement(BaseElement): def __init__(self, value=None): super(ValuedBaseElement, self).__init__(value=value) caldav-0.11.0/caldav/elements/cdav.py000066400000000000000000000115071433711056000173640ustar00rootroot00000000000000#!/usr/bin/env python # -*- encoding: utf-8 -*- from datetime import datetime try: from datetime import timezone utc_tz = timezone.utc except: ## pytz is deprecated - but as of 2021-11, the icalendar library is only ## compatible with pytz (see https://github.com/collective/icalendar/issues/333 https://github.com/collective/icalendar/issues/335 https://github.com/collective/icalendar/issues/336) import pytz utc_tz = pytz.utc from caldav.lib.namespace import ns from .base import BaseElement, NamedBaseElement, ValuedBaseElement def _to_utc_date_string(ts): # type (Union[date,datetime]]) -> str """coerce datetimes to UTC (assume localtime if nothing is given)""" if isinstance(ts, datetime): try: ## for any python version, this should work for a non-native ## timestamp. ## in python 3.6 and higher, ts.astimezone() will assume a ## naive timestamp is localtime (and so do we) ts = ts.astimezone(utc_tz) except: ## native time stamp and the current python version is ## not able to treat it as localtime. import tzlocal ts = ts.replace(tzinfo=tzlocal.get_localzone()) ts = ts.astimezone(utc_tz) return ts.strftime("%Y%m%dT%H%M%SZ") # Operations class CalendarQuery(BaseElement): tag = ns("C", "calendar-query") class FreeBusyQuery(BaseElement): tag = ns("C", "free-busy-query") class Mkcalendar(BaseElement): tag = ns("C", "mkcalendar") class CalendarMultiGet(BaseElement): tag = ns("C", "calendar-multiget") class ScheduleInboxURL(BaseElement): tag = ns("C", "schedule-inbox-URL") class ScheduleOutboxURL(BaseElement): tag = ns("C", "schedule-outbox-URL") # Filters class Filter(BaseElement): tag = ns("C", "filter") class CompFilter(NamedBaseElement): tag = ns("C", "comp-filter") class PropFilter(NamedBaseElement): tag = ns("C", "prop-filter") class ParamFilter(NamedBaseElement): tag = ns("C", "param-filter") # Conditions class TextMatch(ValuedBaseElement): tag = ns("C", "text-match") def __init__(self, value, collation="i;octet", negate=False): super(TextMatch, self).__init__(value=value) self.attributes["collation"] = collation if negate: self.attributes["negate-condition"] = "yes" class TimeRange(BaseElement): tag = ns("C", "time-range") def __init__(self, start=None, end=None): ## start and end should be an icalendar "date with UTC time", ## ref https://tools.ietf.org/html/rfc4791#section-9.9 super(TimeRange, self).__init__() if start is not None: self.attributes["start"] = _to_utc_date_string(start) if end is not None: self.attributes["end"] = _to_utc_date_string(end) class NotDefined(BaseElement): tag = ns("C", "is-not-defined") # Components / Data class CalendarData(BaseElement): tag = ns("C", "calendar-data") class Expand(BaseElement): tag = ns("C", "expand") def __init__(self, start, end=None): super(Expand, self).__init__() if start is not None: self.attributes["start"] = _to_utc_date_string(start) if end is not None: self.attributes["end"] = _to_utc_date_string(end) class Comp(NamedBaseElement): tag = ns("C", "comp") # Uhhm ... can't find any references to calendar-collection in rfc4791.txt # and newer versions of baikal gives 403 forbidden when this one is # encountered # class CalendarCollection(BaseElement): # tag = ns("C", "calendar-collection") # Properties class CalendarUserAddressSet(BaseElement): tag = ns("C", "calendar-user-address-set") class CalendarUserType(BaseElement): tag = ns("C", "calendar-user-type") class CalendarHomeSet(BaseElement): tag = ns("C", "calendar-home-set") # calendar resource type, see rfc4791, sec. 4.2 class Calendar(BaseElement): tag = ns("C", "calendar") class CalendarDescription(ValuedBaseElement): tag = ns("C", "calendar-description") class CalendarTimeZone(ValuedBaseElement): tag = ns("C", "calendar-timezone") class SupportedCalendarComponentSet(ValuedBaseElement): tag = ns("C", "supported-calendar-component-set") class SupportedCalendarData(ValuedBaseElement): tag = ns("C", "supported-calendar-data") class MaxResourceSize(ValuedBaseElement): tag = ns("C", "max-resource-size") class MinDateTime(ValuedBaseElement): tag = ns("C", "min-date-time") class MaxDateTime(ValuedBaseElement): tag = ns("C", "max-date-time") class MaxInstances(ValuedBaseElement): tag = ns("C", "max-instances") class MaxAttendeesPerInstance(ValuedBaseElement): tag = ns("C", "max-attendees-per-instance") class Allprop(BaseElement): tag = ns("C", "allprop") class ScheduleTag(BaseElement): tag = ns("C", "schedule-tag") caldav-0.11.0/caldav/elements/dav.py000066400000000000000000000031111433711056000172110ustar00rootroot00000000000000#!/usr/bin/env python # -*- encoding: utf-8 -*- from caldav.lib.namespace import ns from .base import BaseElement from .base import ValuedBaseElement # Operations class Propfind(BaseElement): tag = ns("D", "propfind") class PropertyUpdate(BaseElement): tag = ns("D", "propertyupdate") class Mkcol(BaseElement): tag = ns("D", "mkcol") class SyncCollection(BaseElement): tag = ns("D", "sync-collection") # Filters # Conditions class SyncToken(BaseElement): tag = ns("D", "sync-token") class SyncLevel(BaseElement): tag = ns("D", "sync-level") # Components / Data class Prop(BaseElement): tag = ns("D", "prop") class Collection(BaseElement): tag = ns("D", "collection") class Set(BaseElement): tag = ns("D", "set") # Properties class ResourceType(BaseElement): tag = ns("D", "resourcetype") class DisplayName(ValuedBaseElement): tag = ns("D", "displayname") class GetEtag(ValuedBaseElement): tag = ns("D", "getetag") class Href(BaseElement): tag = ns("D", "href") class SupportedReportSet(BaseElement): tag = ns("D", "supported-report-set") class Response(BaseElement): tag = ns("D", "response") class Status(BaseElement): tag = ns("D", "status") class PropStat(BaseElement): tag = ns("D", "propstat") class MultiStatus(BaseElement): tag = ns("D", "multistatus") class CurrentUserPrincipal(BaseElement): tag = ns("D", "current-user-principal") class PrincipalCollectionSet(BaseElement): tag = ns("D", "principal-collection-set") class Allprop(BaseElement): tag = ns("D", "allprop") caldav-0.11.0/caldav/elements/ical.py000066400000000000000000000004771433711056000173630ustar00rootroot00000000000000#!/usr/bin/env python # -*- encoding: utf-8 -*- from caldav.lib.namespace import ns from .base import BaseElement from .base import ValuedBaseElement # Properties class CalendarColor(ValuedBaseElement): tag = ns("I", "calendar-color") class CalendarOrder(ValuedBaseElement): tag = ns("I", "calendar-order") caldav-0.11.0/caldav/lib/000077500000000000000000000000001433711056000150235ustar00rootroot00000000000000caldav-0.11.0/caldav/lib/__init__.py000066400000000000000000000000001433711056000171220ustar00rootroot00000000000000caldav-0.11.0/caldav/lib/debug.py000066400000000000000000000002571433711056000164670ustar00rootroot00000000000000from lxml import etree def xmlstring(root): return etree.tostring(root.xmlelement(), pretty_print=True).decode("utf-8") def printxml(root): print(xmlstring(root)) caldav-0.11.0/caldav/lib/error.py000066400000000000000000000051041433711056000165260ustar00rootroot00000000000000#!/usr/bin/env python # -*- encoding: utf-8 -*- import logging from collections import defaultdict try: import os ## one of DEBUG_PDB, DEBUG, DEVELOPMENT, PRODUCTION debugmode = os.environ["PYTHON_CALDAV_DEBUGMODE"] except: ## The default debugmode should be PRODUCTION in official releases, ## and DEVELOPMENT when doing beta testing. ## TODO: find some way to automate this. # debugmode = "DEVELOPMENT" debugmode = "PRODUCTION" log = logging.getLogger("caldav") if debugmode.startswith("DEBUG"): log.setLevel(logging.DEBUG) else: log.setLevel(logging.WARNING) def assert_(condition): try: assert condition except AssertionError: if debugmode == "PRODUCTION": log.error( "Deviation from expectations found. %s" % ERR_FRAGMENT, exc_info=True ) elif debugmode == "DEBUG_PDB": log.error("Deviation from expectations found. Dropping into debugger") import pdb pdb.set_trace() else: raise ERR_FRAGMENT = "Please raise an issue at https://github.com/python-caldav/caldav/issues or reach out to t-caldav@tobixen.no, include this error and the traceback and tell what server you are using" class DAVError(Exception): url = None reason = "no reason" def __init__(self, url=None, reason=None): if url: self.url = url if reason: self.reason = reason def __str__(self): return "%s at '%s', reason %s" % ( self.__class__.__name__, self.url, self.reason, ) class AuthorizationError(DAVError): """ The client encountered an HTTP 403 error and is passing it on to the user. The url property will contain the url in question, the reason property will contain the excuse the server sent. """ pass class PropsetError(DAVError): pass class ProppatchError(DAVError): pass class PropfindError(DAVError): pass class ReportError(DAVError): pass class MkcolError(DAVError): pass class MkcalendarError(DAVError): pass class PutError(DAVError): pass class DeleteError(DAVError): pass class NotFoundError(DAVError): pass class ConsistencyError(DAVError): pass class ResponseError(DAVError): pass exception_by_method = defaultdict(lambda: DAVError) for method in ( "delete", "put", "mkcalendar", "mkcol", "report", "propset", "propfind", "proppatch", ): exception_by_method[method] = locals()[method[0].upper() + method[1:] + "Error"] caldav-0.11.0/caldav/lib/namespace.py000066400000000000000000000012341433711056000173310ustar00rootroot00000000000000#!/usr/bin/env python # -*- encoding: utf-8 -*- nsmap = { "D": "DAV:", "C": "urn:ietf:params:xml:ns:caldav", } ## silly thing with this one ... but quite many caldav libraries, ## caldav clients and caldav servers supports this namespace and the ## calendar-color and calendar-order properties. However, those ## attributes aren't described anywhere, and the I-URL even gives a ## 404! I don't want to ship it in the namespace list of every request. nsmap2 = nsmap.copy() nsmap2["I"] = ("http://apple.com/ns/ical/",) def ns(prefix, tag=None): name = "{%s}" % nsmap2[prefix] if tag is not None: name = "%s%s" % (name, tag) return name caldav-0.11.0/caldav/lib/python_utilities.py000066400000000000000000000025621433711056000210160ustar00rootroot00000000000000from six import PY3 from six import string_types def isPython3(): """Deprecated. Use six.PY3""" return PY3 def to_wire(text): if text is None: return None if isinstance(text, string_types) and PY3: text = bytes(text, "utf-8") elif not PY3: text = to_unicode(text).encode("utf-8") text = text.replace(b"\n", b"\r\n") text = text.replace(b"\r\r\n", b"\r\n") return text def to_local(text): if text is None: return None if not isinstance(text, string_types): text = text.decode("utf-8") text = text.replace("\r\n", "\n") return text to_str = to_local def to_normal_str(text): """ A str object is a unicode on python3 and a byte string on python2. Make sure we return a normal string, no matter what version of python ... """ if text is None: return text if PY3 and not isinstance(text, str): text = text.decode("utf-8") elif not PY3 and not isinstance(text, str): text = text.encode("utf-8") text = text.replace("\r\n", "\n") return text def to_unicode(text): if ( text and isinstance(text, string_types) and not PY3 and not isinstance(text, unicode) ): return unicode(text, "utf-8") if PY3 and text and isinstance(text, bytes): return text.decode("utf-8") return text caldav-0.11.0/caldav/lib/url.py000066400000000000000000000152131433711056000162010ustar00rootroot00000000000000#!/usr/bin/env python # -*- encoding: utf-8 -*- from caldav.lib.python_utilities import to_normal_str from caldav.lib.python_utilities import to_unicode from six import PY3 if PY3: from urllib.parse import ( ParseResult, SplitResult, urlparse, unquote, quote, urlunparse, ) else: from urlparse import ParseResult, SplitResult from urlparse import urlparse, urlunparse from urllib import unquote, quote def uc2utf8(input): # argh! this feels wrong, but seems to be needed. if not PY3 and type(input) == unicode: return input.encode("utf-8") else: return input class URL: """ This class is for wrapping URLs into objects. It's used internally in the library, end users should not need to know anything about this class. All methods that accept URLs can be fed either with an URL object, a string or an urlparse.ParsedURL object. Addresses may be one out of three: 1) a path relative to the DAV-root, i.e. "someuser/calendar" may refer to "http://my.davical-server.example.com/caldav.php/someuser/calendar". 2) an absolute path, i.e. "/caldav.php/someuser/calendar" 3) a fully qualified URL, i.e. "http://someuser:somepass@my.davical-server.example.com/caldav.php/someuser/calendar". Remark that hostname, port, user, pass is typically given when instantiating the DAVClient object and cannot be overridden later. As of 2013-11, some methods in the caldav library expected strings and some expected urlParseResult objects, some expected fully qualified URLs and most expected absolute paths. The purpose of this class is to ensure consistency and at the same time maintaining backward compatibility. Basically, all methods should accept any kind of URL. """ def __init__(self, url): if isinstance(url, ParseResult) or isinstance(url, SplitResult): self.url_parsed = url self.url_raw = None else: self.url_raw = url self.url_parsed = None def __bool__(self): if self.url_raw or self.url_parsed: return True else: return False def __ne__(self, other): return not self == other def __eq__(self, other): if str(self) == str(other): return True # The URLs could have insignificant differences me = self.canonical() if hasattr(other, "canonical"): other = other.canonical() return str(me) == str(other) def __hash__(self): return hash(str(self)) # TODO: better naming? Will return url if url is already an URL # object, else will instantiate a new URL object @classmethod def objectify(self, url): if url is None: return None if isinstance(url, URL): return url else: return URL(url) # To deal with all kind of methods/properties in the ParseResult # class def __getattr__(self, attr): if "url_parsed" not in vars(self): raise AttributeError if self.url_parsed is None: self.url_parsed = urlparse(self.url_raw) if hasattr(self.url_parsed, attr): return getattr(self.url_parsed, attr) else: return getattr(self.__unicode__(), attr) # returns the url in text format def __str__(self): return to_normal_str(self.__unicode__()) # returns the url in text format def __unicode__(self): if self.url_raw is None: self.url_raw = self.url_parsed.geturl() return to_unicode(self.url_raw) def __repr__(self): return "URL(%s)" % str(self) def strip_trailing_slash(self): if str(self)[-1] == "/": return URL.objectify(str(self)[:-1]) else: return self def is_auth(self): return self.username is not None def unauth(self): if not self.is_auth(): return self return URL.objectify( ParseResult( self.scheme, "%s:%s" % (self.hostname, self.port or {"https": 443, "http": 80}[self.scheme]), self.path.replace("//", "/"), self.params, self.query, self.fragment, ) ) def canonical(self): """ a canonical URL ... remove authentication details, make sure there are no double slashes, and to make sure the URL is always the same, run it through the urlparser, and make sure path is properly quoted """ url = self.unauth() arr = list(self.url_parsed) ## quoting path and removing double slashes arr[2] = quote(unquote(url.path.replace("//", "/"))) ## sensible defaults if not arr[0]: arr[0] = "https" if arr[1] and not ":" in arr[1]: if arr[0] == "https": portpart = ":443" elif arr[0] == "http": portpart = ":80" else: portpart = "" arr[1] += portpart # make sure to delete the string version url.url_raw = urlunparse(arr) url.url_parsed = None return url def join(self, path): """ assumes this object is the base URL or base path. If the path is relative, it should be appended to the base. If the path is absolute, it should be added to the connection details of self. If the path already contains connection details and the connection details differ from self, raise an error. """ pathAsString = str(path) if not path or not pathAsString: return self path = URL.objectify(path) if ( (path.scheme and self.scheme and path.scheme != self.scheme) or (path.hostname and self.hostname and path.hostname != self.hostname) or (path.port and self.port and path.port != self.port) ): raise ValueError("%s can't be joined with %s" % (self, path)) if path.path[0] == "/": ret_path = uc2utf8(path.path) else: sep = "/" if self.path.endswith("/"): sep = "" ret_path = "%s%s%s" % (self.path, sep, uc2utf8(path.path)) return URL( ParseResult( self.scheme or path.scheme, self.netloc or path.netloc, ret_path, path.params, path.query, path.fragment, ) ) def make(url): """Backward compatibility""" return URL.objectify(url) caldav-0.11.0/caldav/lib/vcal.py000066400000000000000000000113251433711056000163240ustar00rootroot00000000000000#!/usr/bin/env python # -*- encoding: utf-8 -*- import datetime import re import uuid import icalendar from caldav.lib.python_utilities import to_normal_str ## Fixups to the icalendar data to work around compatbility issues. ## TODO: ## 1) this should only be done if needed. Use try-except around the ## fragments where icalendar/vobject is parsing ical data, and do the ## fixups there. ## 2) arguably, this is outside the scope of the caldav library. ## check if this can be done in vobject or icalendar libraries instead ## of here ## TODO: would be nice with proper documentation on what systems are ## generating broken data. Compatibility issues should also be collected ## in the documentation. somewhere. def fix(event): """This function receives some ical as it's given from the server, checks for breakages with the standard, and attempts to fix up known issues: 1) COMPLETED MUST be a datetime in UTC according to the RFC, but sometimes a date is given. (Google Calendar?) 2) The RFC does not specify any range restrictions on the dates, but clearly it doesn't make sense with a CREATED-timestamp that is centuries or decades before RFC2445 was published in 1998. Apparently some calendar servers generate nonsensical CREATED timestamps while other calendar servers can't handle CREATED timestamps prior to 1970. Probably it would make more sense to drop the CREATED line completely rather than moving it from the end of year 0AD to the beginning of year 1970. (Google Calendar) 3) iCloud apparently duplicates the DTSTAMP property sometimes - keep the first DTSTAMP encountered (arguably the DTSTAMP with earliest value should be kept). 4) ref https://github.com/python-caldav/caldav/issues/37, X-APPLE-STRUCTURED-EVENT attribute sometimes comes with trailing white space. I've decided to remove all trailing spaces, since they seem to cause a traceback with vobject and those lines are simply ignored by icalendar. """ ## TODO: add ^ before COMPLETED and CREATED? ## 1) Add a random time if completed is given as date fixed = re.sub( r"COMPLETED:(\d+)\s", r"COMPLETED:\g<1>T120000Z", to_normal_str(event) ) ## 2) CREATED timestamps prior to epoch does not make sense, ## change from year 0001 to epoch. fixed = re.sub("CREATED:00001231T000000Z", "CREATED:19700101T000000Z", fixed) fixed = re.sub(r"\\+('\")", r"\1", fixed) ## 4) trailing whitespace probably never makes sense fixed = re.sub(" *$", "", fixed) ## 3 fix duplicated DTSTAMP ## OPTIMIZATION TODO: use list and join rather than concatination ## remove duplication of DTSTAMP fixed2 = "" for line in fixed.strip().split("\n"): if line.startswith("BEGIN:V"): cnt = 0 if line.startswith("DTSTAMP:"): if not cnt: fixed2 += line + "\n" cnt += 1 else: fixed2 += line + "\n" return fixed2 ## sorry for being english-language-euro-centric ... fits rather perfectly as default language for me :-) def create_ical(ical_fragment=None, objtype=None, language="en_DK", **props): """ I somehow feel this fits more into the icalendar library than here """ ical_fragment = to_normal_str(ical_fragment) if not ical_fragment or not re.search("^BEGIN:V", ical_fragment, re.MULTILINE): my_instance = icalendar.Calendar() my_instance.add("prodid", "-//python-caldav//caldav//" + language) my_instance.add("version", "2.0") if objtype is None: objtype = "VEVENT" component = icalendar.cal.component_factory[objtype]() component.add("dtstamp", datetime.datetime.now()) component.add("uid", uuid.uuid1()) my_instance.add_component(component) else: if not ical_fragment.startswith("BEGIN:VCALENDAR"): ical_fragment = ( "BEGIN:VCALENDAR\n" + to_normal_str(ical_fragment.strip()) + "\nEND:VCALENDAR\n" ) my_instance = icalendar.Calendar.from_ical(ical_fragment) component = my_instance.subcomponents[0] ical_fragment = None for prop in props: if props[prop] is not None: if prop in ("child", "parent"): for value in props[prop]: component.add( "related-to", props[prop], parameters={"rel-type": prop.upper()} ) else: component.add(prop, props[prop]) ret = to_normal_str(my_instance.to_ical()) if ical_fragment and ical_fragment.strip(): ret = re.sub( "^END:V", ical_fragment.strip() + "\nEND:V", ret, flags=re.MULTILINE ) return ret caldav-0.11.0/caldav/objects.py000066400000000000000000003061121433711056000162630ustar00rootroot00000000000000#!/usr/bin/env python # -*- encoding: utf-8 -*- """ A "DAV object" is anything we get from the caldav server or push into the caldav server, notably principal, calendars and calendar events. (This file has become huge and will be split up prior to the next release. I think it makes sense moving the CalendarObjectResource class hierarchy into a separate file) """ import re import uuid from datetime import date from datetime import datetime from datetime import timedelta from datetime import timezone import icalendar import vobject from caldav.lib.python_utilities import to_normal_str from caldav.lib.python_utilities import to_unicode from caldav.lib.python_utilities import to_wire from dateutil.rrule import rrulestr from lxml import etree try: # noinspection PyCompatibility from urllib.parse import unquote, quote except ImportError: from urllib import unquote, quote try: from typing import Union, Optional TimeStamp = Optional[Union[date, datetime]] except: pass from caldav.lib import error, vcal from caldav.lib.url import URL from caldav.elements import dav, cdav, ical import logging log = logging.getLogger("caldav") def errmsg(r): """Utility for formatting a response xml tree to an error string""" return "%s %s\n\n%s" % (r.status, r.reason, r.raw) class DAVObject(object): """ Base class for all DAV objects. Can be instantiated by a client and an absolute or relative URL, or from the parent object. """ id = None url = None client = None parent = None name = None def __init__( self, client=None, url=None, parent=None, name=None, id=None, props=None, **extra ): """ Default constructor. Parameters: * client: A DAVClient instance * url: The url for this object. May be a full URL or a relative URL. * parent: The parent object - used when creating objects * name: A displayname - to be removed in 1.0, see https://github.com/python-caldav/caldav/issues/128 for details * props: a dict with known properties for this object (as of 2020-12, only used for etags, and only when fetching CalendarObjectResource using the .objects or .objects_by_sync_token methods). * id: The resource id (UID for an Event) """ if client is None and parent is not None: client = parent.client self.client = client self.parent = parent self.name = name self.id = id if props is None: self.props = {} else: self.props = props self.extra_init_options = extra # url may be a path relative to the caldav root if client and url: self.url = client.url.join(url) else: self.url = URL.objectify(url) @property def canonical_url(self): return str(self.url.canonical()) def children(self, type=None): """ List children, using a propfind (resourcetype) on the parent object, at depth = 1. """ c = [] depth = 1 properties = {} props = [dav.DisplayName()] multiprops = [dav.ResourceType()] response = self._query_properties(props + multiprops, depth) properties = response.expand_simple_props( props=props, multi_value_props=multiprops ) for path in list(properties.keys()): resource_types = properties[path][dav.ResourceType.tag] resource_name = properties[path][dav.DisplayName.tag] if type is None or type in resource_types: url = URL(path) if url.hostname is None: # Quote when path is not a full URL path = quote(path) # TODO: investigate the RFCs thoroughly - why does a "get # members of this collection"-request also return the # collection URL itself? # And why is the strip_trailing_slash-method needed? # The collection URL should always end with a slash according # to RFC 2518, section 5.2. if (isinstance(self, CalendarSet) and type == cdav.Calendar.tag) or ( self.url.canonical().strip_trailing_slash() != self.url.join(path).canonical().strip_trailing_slash() ): c.append((self.url.join(path), resource_types, resource_name)) ## TODO: return objects rather than just URLs, and include ## the properties we've already fetched return c def _query_properties(self, props=None, depth=0): """ This is an internal method for doing a propfind query. It's a result of code-refactoring work, attempting to consolidate similar-looking code into a common method. """ root = None # build the propfind request if props is not None and len(props) > 0: prop = dav.Prop() + props root = dav.Propfind() + prop return self._query(root, depth) def _query( self, root=None, depth=0, query_method="propfind", url=None, expected_return_value=None, ): """ This is an internal method for doing a query. It's a result of code-refactoring work, attempting to consolidate similar-looking code into a common method. """ body = "" if root: if hasattr(root, "xmlelement"): body = etree.tostring( root.xmlelement(), encoding="utf-8", xml_declaration=True ) else: body = root if url is None: url = self.url ret = getattr(self.client, query_method)(url, body, depth) if ret.status == 404: raise error.NotFoundError(errmsg(ret)) if ( expected_return_value is not None and ret.status != expected_return_value ) or ret.status >= 400: raise error.exception_by_method[query_method](errmsg(ret)) return ret def get_property(self, prop, use_cached=False, **passthrough): ## TODO: use_cached should probably be true if use_cached: if prop.tag in self.props: return self.props[prop.tag] foo = self.get_properties([prop], **passthrough) return foo.get(prop.tag, None) def get_properties( self, props=None, depth=0, parse_response_xml=True, parse_props=True ): """Get properties (PROPFIND) for this object. With parse_response_xml and parse_props set to True a best-attempt will be done on decoding the XML we get from the server - but this works only for properties that don't have complex types. With parse_response_xml set to False, a DAVResponse object will be returned, and it's up to the caller to decode. With parse_props set to false but parse_response_xml set to true, xml elements will be returned rather than values. Parameters: * props = [dav.ResourceType(), dav.DisplayName(), ...] Returns: * {proptag: value, ...} """ rc = None response = self._query_properties(props, depth) if not parse_response_xml: return response if not parse_props: properties = response.find_objects_and_props() else: properties = response.expand_simple_props(props) error.assert_(properties) path = unquote(self.url.path) if path.endswith("/"): exchange_path = path[:-1] else: exchange_path = path + "/" if path in properties: rc = properties[path] elif exchange_path in properties: if not isinstance(self, Principal): ## Some caldav servers reports the URL for the current ## principal to end with / when doing a propfind for ## current-user-principal - I believe that's a bug, ## the principal is not a collection and should not ## end with /. (example in rfc5397 does not end with /). ## ... but it gets worse ... when doing a propfind on the ## principal, the href returned may be without the slash. ## Such inconsistency is clearly a bug. log.error( "potential path handling problem with ending slashes. Path given: %s, path found: %s. %s" % (path, exchange_path, error.ERR_FRAGMENT) ) error._assert(False) rc = properties[exchange_path] elif self.url in properties: rc = properties[self.url] elif "/principal/" in properties and path.endswith("/principal/"): ## Workaround for a known iCloud bug. ## The properties key is expected to be the same as the path. ## path is on the format /123456/principal/ but properties key is /principal/ ## tests apparently passed post bc589093a34f0ed0ef489ad5e9cba048750c9837 and 3ee4e42e2fa8f78b71e5ffd1ef322e4007df7a60, even without this workaround ## TODO: should probably be investigated more. ## (observed also by others, ref https://github.com/python-caldav/caldav/issues/168) rc = properties["/principal/"] elif len(properties) == 1: ## Ref https://github.com/python-caldav/caldav/issues/191 ... ## let's be pragmatic and just accept whatever the server is ## throwing at us. But we'll log an error anyway. log.error( "Possibly the server has a path handling problem, possibly the URL configured is wrong.\n" "Path expected: %s, path found: %s %s.\n" "Continuing, probably everything will be fine" % (path, str(list(properties.keys())), error.ERR_FRAGMENT) ) rc = list(properties.values())[0] else: log.error( "Possibly the server has a path handling problem. Path expected: %s, paths found: %s %s" % (path, str(list(properties.keys())), error.ERR_FRAGMENT) ) error.assert_(False) if parse_props: self.props.update(rc) return rc def set_properties(self, props=None): """ Set properties (PROPPATCH) for this object. * props = [dav.DisplayName('name'), ...] Returns: * self """ props = [] if props is None else props prop = dav.Prop() + props set = dav.Set() + prop root = dav.PropertyUpdate() + set r = self._query(root, query_method="proppatch") statuses = r.tree.findall(".//" + dav.Status.tag) for s in statuses: if " 200 " not in s.text: raise error.PropsetError(s.text) return self def save(self): """ Save the object. This is an abstract method, that all classes derived from DAVObject implement. Returns: * self """ raise NotImplementedError() def delete(self): """ Delete the object. """ if self.url is not None: r = self.client.delete(self.url) # TODO: find out why we get 404 if r.status not in (200, 204, 404): raise error.DeleteError(errmsg(r)) def get_display_name(self): """ Get calendar display name """ return self.get_property(dav.DisplayName()) def __str__(self): if dav.DisplayName.tag in self.props: return self.props[dav.DisplayName.tag] else: return str(self.url) def __repr__(self): return "%s(%s)" % (self.__class__.__name__, str(self)) class CalendarSet(DAVObject): """ A CalendarSet is a set of calendars. """ def calendars(self): """ List all calendar collections in this set. Returns: * [Calendar(), ...] """ cals = [] data = self.children(cdav.Calendar.tag) for c_url, c_type, c_name in data: cals.append(Calendar(self.client, c_url, parent=self, name=c_name)) return cals def make_calendar( self, name=None, cal_id=None, supported_calendar_component_set=None ): """ Utility method for creating a new calendar. Parameters: * name: the display name of the new calendar * cal_id: the uuid of the new calendar * supported_calendar_component_set: what kind of objects (EVENT, VTODO, VFREEBUSY, VJOURNAL) the calendar should handle. Should be set to ['VTODO'] when creating a task list in Zimbra - in most other cases the default will be OK. Returns: * Calendar(...)-object """ return Calendar( self.client, name=name, parent=self, id=cal_id, supported_calendar_component_set=supported_calendar_component_set, ).save() def calendar(self, name=None, cal_id=None): """ The calendar method will return a calendar object. If it gets a cal_id but no name, it will not initiate any communication with the server Parameters: * name: return the calendar with this display name * cal_id: return the calendar with this calendar id or URL Returns: * Calendar(...)-object """ if name and not cal_id: for calendar in self.calendars(): display_name = calendar.get_display_name() if display_name == name: return calendar if name and not cal_id: raise error.NotFoundError( "No calendar with name %s found under %s" % (name, self.url) ) if not cal_id and not name: return self.calendars()[0] if str(URL.objectify(cal_id).canonical()).startswith( str(self.client.url.canonical()) ): url = self.client.url.join(cal_id) elif ( isinstance(cal_id, URL) or cal_id.startswith("https://") or cal_id.startswith("http://") ): url = self.url.join(cal_id) else: url = self.url.join(quote(cal_id) + "/") return Calendar(self.client, name=name, parent=self, url=url, id=cal_id) class Principal(DAVObject): """ This class represents a DAV Principal. It doesn't do much, except keep track of the URLs for the calendar-home-set, etc. A principal MUST have a non-empty DAV:displayname property (defined in Section 13.2 of [RFC2518]), and a DAV:resourcetype property (defined in Section 13.9 of [RFC2518]). Additionally, a principal MUST report the DAV:principal XML element in the value of the DAV:resourcetype property. (TODO: the resourcetype is actually never checked, and the DisplayName is not stored anywhere) """ def __init__(self, client=None, url=None): """ Returns a Principal. Parameters: * client: a DAVClient() oject * url: Deprecated - for backwards compatibility purposes only. If url is not given, deduct principal path as well as calendar home set path from doing propfinds. """ super(Principal, self).__init__(client=client, url=url) self._calendar_home_set = None if url is None: self.url = self.client.url cup = self.get_property(dav.CurrentUserPrincipal()) self.url = self.client.url.join(URL.objectify(cup)) def make_calendar( self, name=None, cal_id=None, supported_calendar_component_set=None ): """ Convenience method, bypasses the self.calendar_home_set object. See CalendarSet.make_calendar for details. """ return self.calendar_home_set.make_calendar( name, cal_id, supported_calendar_component_set=supported_calendar_component_set, ) def calendar(self, name=None, cal_id=None): """ The calendar method will return a calendar object. It will not initiate any communication with the server. """ return self.calendar_home_set.calendar(name, cal_id) def get_vcal_address(self): """ Returns the principal, as an icalendar.vCalAddress object """ from icalendar import vCalAddress, vText cn = self.get_display_name() ids = self.calendar_user_address_set() cutype = self.get_property(cdav.CalendarUserType()) ret = vCalAddress(ids[0]) ret.params["cn"] = vText(cn) ret.params["cutype"] = vText(cutype) return ret @property def calendar_home_set(self): if not self._calendar_home_set: calendar_home_set_url = self.get_property(cdav.CalendarHomeSet()) ## owncloud returns /remote.php/dav/calendars/tobixen@e.email/ ## in that case the @ should be quoted. Perhaps other ## implentations returns already quoted URLs. Hacky workaround: if ( calendar_home_set_url is not None and "@" in calendar_home_set_url and not "://" in calendar_home_set_url ): calendar_home_set_url = quote(calendar_home_set_url) self.calendar_home_set = calendar_home_set_url return self._calendar_home_set @calendar_home_set.setter def calendar_home_set(self, url): if isinstance(url, CalendarSet): self._calendar_home_set = url return sanitized_url = URL.objectify(url) ## TODO: sanitized_url should never be None, this needs more ## research. added here as it solves real-world issues, ref ## https://github.com/python-caldav/caldav/pull/56 if sanitized_url is not None: if ( sanitized_url.hostname and sanitized_url.hostname != self.client.url.hostname ): # icloud (and others?) having a load balanced system, # where each principal resides on one named host ## TODO: ## Here be dragons. sanitized_url will be the root ## of all future objects derived from client. Changing ## the client.url root by doing a principal.calendars() ## is an unacceptable side effect and may be a cause of ## incompatibilities with icloud. Do more research! self.client.url = sanitized_url self._calendar_home_set = CalendarSet( self.client, self.client.url.join(sanitized_url) ) def calendars(self): """ Return the principials calendars """ return self.calendar_home_set.calendars() def freebusy_request(self, dtstart, dtend, attendees): freebusy_ical = icalendar.Calendar() freebusy_ical.add("prodid", "-//tobixen/python-caldav//EN") freebusy_ical.add("version", "2.0") freebusy_ical.add("method", "REQUEST") uid = uuid.uuid1() freebusy_comp = icalendar.FreeBusy() freebusy_comp.add("uid", uid) freebusy_comp.add("dtstamp", datetime.now()) freebusy_comp.add("dtstart", dtstart) freebusy_comp.add("dtend", dtend) freebusy_ical.add_component(freebusy_comp) outbox = self.schedule_outbox() caldavobj = FreeBusy(data=freebusy_ical, parent=outbox) caldavobj.add_organizer() for attendee in attendees: caldavobj.add_attendee(attendee, no_default_parameters=True) response = self.client.post( outbox.url, caldavobj.data, headers={"Content-Type": "text/calendar; charset=utf-8"}, ) return response.find_objects_and_props() def calendar_user_address_set(self): """ defined in RFC6638 """ addresses = self.get_property(cdav.CalendarUserAddressSet(), parse_props=False) assert not [x for x in addresses if x.tag != dav.Href().tag] addresses = list(addresses) ## possibly the preferred attribute is iCloud-specific. ## TODO: do more research on that addresses.sort(key=lambda x: -int(x.get("preferred", 0))) return [x.text for x in addresses] def schedule_inbox(self): return ScheduleInbox(principal=self) def schedule_outbox(self): return ScheduleOutbox(principal=self) class Calendar(DAVObject): """ The `Calendar` object is used to represent a calendar collection. Refer to the RFC for details: https://tools.ietf.org/html/rfc4791#section-5.3.1 """ def _create(self, name=None, id=None, supported_calendar_component_set=None): """ Create a new calendar with display name `name` in `parent`. """ if id is None: id = str(uuid.uuid1()) self.id = id path = self.parent.url.join(id + "/") self.url = path # TODO: mkcalendar seems to ignore the body on most servers? # at least the name doesn't get set this way. # zimbra gives 500 (!) if body is omitted ... prop = dav.Prop() if name: display_name = dav.DisplayName(name) prop += [ display_name, ] if supported_calendar_component_set: sccs = cdav.SupportedCalendarComponentSet() for scc in supported_calendar_component_set: sccs += cdav.Comp(scc) prop += sccs set = dav.Set() + prop mkcol = cdav.Mkcalendar() + set r = self._query( root=mkcol, query_method="mkcalendar", url=path, expected_return_value=201 ) # COMPATIBILITY ISSUE # name should already be set, but we've seen caldav servers failing # on setting the DisplayName on calendar creation # (DAViCal, Zimbra, ...). Doing an attempt on explicitly setting the # display name using PROPPATCH. if name: try: self.set_properties([display_name]) except: ## TODO: investigate. Those asserts break. error.assert_(False) try: current_display_name = self.get_display_name() error.assert_(current_display_name == name) except: log.warning( "calendar server does not support display name on calendar? Ignoring", exc_info=True, ) error.assert_(False) def get_supported_components(self): """ returns a list of component types supported by the calendar, in string format (typically ['VJOURNAL', 'VTODO', 'VEVENT']) """ props = [cdav.SupportedCalendarComponentSet()] response = self.get_properties(props, parse_response_xml=False) response_list = response.find_objects_and_props() prop = response_list[unquote(self.url.path)][ cdav.SupportedCalendarComponentSet().tag ] return [supported.get("name") for supported in prop] def save_with_invites(self, ical, attendees, **attendeeoptions): """ sends a schedule request to the server. Equivalent with save_event, save_todo, etc, but the attendees will be added to the ical object before sending it to the server. """ ## TODO: consolidate together with save_* obj = self._calendar_comp_class_by_data(ical)(data=ical, client=self.client) obj.parent = self obj.add_organizer() for attendee in attendees: obj.add_attendee(attendee, **attendeeoptions) obj.id = obj.icalendar_instance.walk("vevent")[0]["uid"] obj.save() return obj def _use_or_create_ics(self, ical, objtype, **ical_data): if ical_data or ( (isinstance(ical, str) or isinstance(ical, bytes)) and not b"BEGIN:VCALENDAR" in to_wire(ical) ): return vcal.create_ical(ical_fragment=ical, objtype=objtype, **ical_data) return ical ## TODO: consolidate save_* - too much code duplication here def save_event(self, ical=None, no_overwrite=False, no_create=False, **ical_data): """ Add a new event to the calendar, with the given ical. Parameters: * ical - ical object (text) * no_overwrite - existing calendar objects should not be overwritten * no_create - don't create a new object, existing calendar objects should be updated * ical_data - passed to lib.vcal.create_ical """ e = Event( self.client, data=self._use_or_create_ics(ical, objtype="VEVENT", **ical_data), parent=self, ) e.save(no_overwrite=no_overwrite, no_create=no_create, obj_type="event") self._handle_relations(e.id, ical_data) return e def save_todo(self, ical=None, no_overwrite=False, no_create=False, **ical_data): """ Add a new task to the calendar, with the given ical. Parameters: * ical - ical object (text) """ t = Todo( self.client, data=self._use_or_create_ics(ical, objtype="VTODO", **ical_data), parent=self, ) t.save(no_overwrite=no_overwrite, no_create=no_create, obj_type="todo") self._handle_relations(t.id, ical_data) return t def save_journal(self, ical=None, no_overwrite=False, no_create=False, **ical_data): """ Add a new journal entry to the calendar, with the given ical. Parameters: * ical - ical object (text) """ j = Journal( self.client, data=self._use_or_create_ics(ical, objtype="VJOURNAL", **ical_data), parent=self, ) j.save(no_overwrite=no_overwrite, no_create=no_create, obj_type="journal") self._handle_relations(j.id, ical_data) return j def _handle_relations(self, uid, ical_data): for reverse_reltype, other_uid in [ ("parent", x) for x in ical_data.get("child", ()) ] + [("child", x) for x in ical_data.get("parent", ())]: other = self.object_by_uid(other_uid) other.set_relation(other=uid, reltype=reverse_reltype, set_reverse=False) ## legacy aliases add_event = save_event add_todo = save_todo add_journal = save_journal def save(self): """ The save method for a calendar is only used to create it, for now. We know we have to create it when we don't have a url. Returns: * self """ if self.url is None: self._create(id=self.id, name=self.name, **self.extra_init_options) return self def calendar_multiget(self, event_urls): """ get multiple events' data @author mtorange@gmail.com @type events list of Event """ rv = [] prop = dav.Prop() + cdav.CalendarData() root = ( cdav.CalendarMultiGet() + prop + [dav.Href(value=u.path) for u in event_urls] ) response = self._query(root, 1, "report") results = response.expand_simple_props([cdav.CalendarData()]) for r in results: rv.append( Event( self.client, url=self.url.join(r), data=results[r][cdav.CalendarData.tag], parent=self, ) ) return rv ## TODO: Upgrade the warning to an error (and perhaps critical) in future ## releases, and then finally remove this method completely. def build_date_search_query( self, start, end=None, compfilter="VEVENT", expand="maybe" ): ## This is dead code. It has no tests. It was made for usage ## by the date_search method, but I've decided not to use it ## there anymore. Most likely nobody is using this, as it's ## sort of an internal method - but for the sake of backward ## compatibility I will keep it for a while. I regret naming ## it build_date_search_query rather than ## _build_date_search_query... logging.warning( "DEPRECATION WARNING: The calendar.build_date_search_query method will be removed in caldav library from version 1.0 or perhaps earlier. Use calendar.build_search_xml_query instead." ) if expand == "maybe": expand = end if compfilter == "VEVENT": comp_class = Event elif compfilter == "VTODO": comp_class = Todo else: comp_class = None return self.build_search_xml_query( comp_class=comp_class, expand=expand, start=start, end=end ) def date_search( self, start, end=None, compfilter="VEVENT", expand="maybe", verify_expand=False ): # type (TimeStamp, TimeStamp, str, str) -> CalendarObjectResource """Deprecated. Use self.search() instead. Search events by date in the calendar. Recurring events are expanded if they are occuring during the specified time frame and if an end timestamp is given. Parameters: * start = datetime.today(). * end = same as above. * compfilter = defaults to events only. Set to None to fetch all calendar components. * expand - should recurrent events be expanded? (to preserve backward-compatibility the default "maybe" will be changed into True unless the date_search is open-ended) * verify_expand - not in use anymore, but kept for backward compatibility Returns: * [CalendarObjectResource(), ...] """ ## TODO: upgrade to warning and error before removing this method logging.info( "DEPRECATION NOTICE: The calendar.date_search method may be removed in some far future release of the caldav library. Use calendar.search instead" ) if verify_expand: logging.warning( "verify_expand in date_search does not work anymore, as we're doing client side expansion instead" ) ## for backward compatibility - expand should be false ## in an open-ended date search, otherwise true if expand == "maybe": expand = end if compfilter == "VEVENT": comp_class = Event elif compfilter == "VTODO": comp_class = Todo else: comp_class = None ## xandikos now yields a 5xx-error when trying to pass ## expand=True, after I prodded the developer that it doesn't ## work. By now there is some workaround in the test code to ## avoid sending expand=True to xandikos, but perhaps we ## should run a try-except-retry here with expand=False in the ## retry, and warnings logged ... or perhaps not. objects = self.search( start=start, end=end, comp_class=comp_class, expand=expand, split_expanded=False, ) return objects def _request_report_build_resultlist( self, xml, comp_class=None, props=None, no_calendardata=False ): """ Takes some input XML, does a report query on a calendar object and returns the resource objects found. TODO: similar code is duplicated many places, we ought to do even more code refactoring """ matches = [] if props is None: props_ = [cdav.CalendarData()] else: props_ = [cdav.CalendarData()] + props response = self._query(xml, 1, "report") results = response.expand_simple_props(props_) for r in results: pdata = results[r] if cdav.CalendarData.tag in pdata: cdata = pdata.pop(cdav.CalendarData.tag) if comp_class is None: comp_class = self._calendar_comp_class_by_data(cdata) else: cdata = None if comp_class is None: ## no CalendarData fetched - which is normal i.e. when doing a sync-token report and only asking for the URLs comp_class = CalendarObjectResource url = URL(r) if url.hostname is None: # Quote when result is not a full URL url = quote(r) ## icloud hack - icloud returns the calendar URL as well as the calendar item URLs if self.url.join(url) == self.url: continue matches.append( comp_class( self.client, url=self.url.join(url), data=cdata, parent=self, props=pdata, ) ) return (response, matches) def search( self, xml=None, comp_class=None, todo=None, include_completed=False, sort_keys=(), split_expanded=True, **kwargs ): """Creates an XML query, does a REPORT request towards the server and returns objects found, eventually sorting them before delivery. This method contains some special logics to ensure that it can consistently return a list of pending tasks on any server implementation. In the future it may also include workarounds and client side filtering to make sure other search results are consistent on different server implementations. Parameters supported: * xml - use this search query, and ignore other filter parameters * comp_class - set to event, todo or journal to restrict search to this resource type. Some server implementations require this to be set. * todo - sets comp_class to Todo, and restricts search to pending tasks, unless the next parameter is set ... * include_completed - include completed tasks * event - sets comp_class to event * text attribute search parameters: category, uid, summary, omment, description, location, status * expand - do server side expanding of recurring events/tasks * start, end: do a time range search * filters - other kind of filters (in lxml tree format) * sort_keys - list of attributes to use when sorting not supported yet: * negated text match * attribute not set """ ## special compatibility-case when searching for pending todos if todo and not include_completed: matches1 = self.search( todo=True, comp_class=comp_class, ignore_completed1=True, include_completed=True, **kwargs ) matches2 = self.search( todo=True, comp_class=comp_class, ignore_completed2=True, include_completed=True, **kwargs ) matches3 = self.search( todo=True, comp_class=comp_class, ignore_completed3=True, include_completed=True, **kwargs ) objects = [] match_set = set() for item in matches1 + matches2 + matches3: if not item.url in match_set: match_set.add(item.url) ## and still, Zimbra seems to deliver too many TODOs in the ## matches2 ... let's do some post-filtering in case the ## server fails in filtering things the right way if "STATUS:NEEDS-ACTION" in item.data or ( not "\nCOMPLETED:" in item.data and not "\nSTATUS:COMPLETED" in item.data and not "\nSTATUS:CANCELLED" in item.data ): objects.append(item) else: if not xml: (xml, comp_class) = self.build_search_xml_query( comp_class=comp_class, todo=todo, **kwargs ) elif kwargs: raise error.ConsistencyError( "Inconsistent usage parameters: xml together with other search options" ) (response, objects) = self._request_report_build_resultlist(xml, comp_class) if kwargs.get("expand", False): ## expand can only be used together with start and end. ## Error checking is done in build_search_xml_query. If ## search is fed with an XML query together with expand, ## then it's considered a "search option", and an error is ## raised above. start = kwargs["start"] end = kwargs["end"] for o in objects: if not o.data: continue components = o.vobject_instance.components() for i in components: if i.name in ("VEVENT", "VTODO"): ## Those recurrance properties should not be in the returns from the server, ## if they are present it indicates that server expand didn't work and we'll ## have to do it on the client side recurrance_properties = ["exdate", "exrule", "rdate", "rrule"] if any(key in recurrance_properties for key in i.contents): o.expand_rrule(start, end) if split_expanded: objects_ = objects objects = [] for o in objects_: objects.extend(o.split_expanded()) def sort_key_func(x): ret = [] for objtype in ("vtodo", "vevent", "vjournal"): if hasattr(x.instance, objtype): vobj = getattr(x.instance, objtype) break defaults = { "due": "2050-01-01", "dtstart": "1970-01-01", "priority": "0", ## Usage of strftime is a simple way to ensure there won't be ## problems if comparing dates with timestamps "isnt_overdue": not ( hasattr(vobj, "due") and vobj.due.value.strftime("%F%H%M%S") < datetime.now().strftime("%F%H%M%S") ), "hasnt_started": ( hasattr(vobj, "dtstart") and vobj.dtstart.value.strftime("%F%H%M%S") > datetime.now().strftime("%F%H%M%S") ), } for sort_key in sort_keys: val = getattr(vobj, sort_key, None) if val is None: ret.append(defaults.get(sort_key, "0")) continue val = val.value if hasattr(val, "strftime"): ret.append(val.strftime("%F%H%M%S")) else: ret.append(val) return ret if sort_keys: objects.sort(key=sort_key_func) return objects def build_search_xml_query( self, comp_class=None, todo=None, ignore_completed1=None, ignore_completed2=None, ignore_completed3=None, event=None, category=None, class_=None, filters=None, expand=None, start=None, end=None, **kwargs ): """This method will produce a caldav search query as an etree object. It is primarily to be used from the search method. See the documentation for the search method for more information. """ # those xml elements are weird. (a+b)+c != a+(b+c). First makes b and c as list members of a, second makes c an element in b which is an element of a. # First objective is to let this take over all xml search query building and see that the current tests pass. # ref https://www.ietf.org/rfc/rfc4791.txt, section 7.8.9 for how to build a todo-query # We'll play with it and don't mind it's getting ugly and don't mind that the test coverage is lacking. # we'll refactor and create some unit tests later, as well as ftests for complicated queries. # build the request data = cdav.CalendarData() if expand: if not start or not end: raise error.ReportError("can't expand without a date range") data += cdav.Expand(start, end) prop = dav.Prop() + data vcalendar = cdav.CompFilter("VCALENDAR") comp_filter = None if not filters: filters = [] vNotCompleted = cdav.TextMatch("COMPLETED", negate=True) vNotCancelled = cdav.TextMatch("CANCELLED", negate=True) vNeedsAction = cdav.TextMatch("NEEDS-ACTION") vStatusNotCompleted = cdav.PropFilter("STATUS") + vNotCompleted vStatusNotCancelled = cdav.PropFilter("STATUS") + vNotCancelled vStatusNeedsAction = cdav.PropFilter("STATUS") + vNeedsAction vStatusNotDefined = cdav.PropFilter("STATUS") + cdav.NotDefined() vNoCompleteDate = cdav.PropFilter("COMPLETED") + cdav.NotDefined() if ignore_completed1: ## This query is quite much in line with https://tools.ietf.org/html/rfc4791#section-7.8.9 filters.extend([vNoCompleteDate, vStatusNotCompleted, vStatusNotCancelled]) elif ignore_completed2: ## some server implementations (i.e. NextCloud ## and Baikal) will yield "false" on a negated TextMatch ## if the field is not defined. Hence, for those ## implementations we need to turn back and ask again ## ... do you have any VTODOs for us where the STATUS ## field is not defined? (ref ## https://github.com/python-caldav/caldav/issues/14) filters.extend([vNoCompleteDate, vStatusNotDefined]) elif ignore_completed3: ## ... and considering recurring tasks we really need to ## look a third time as well, this time for any task with ## the NEEDS-ACTION status set (do we need the first go? ## NEEDS-ACTION or no status set should cover them all?) filters.extend([vStatusNeedsAction]) if start or end: filters.append(cdav.TimeRange(start, end)) if todo is not None: if not todo: raise NotImplementedError() if todo: if comp_class is not None and comp_class is not Todo: raise error.ConsistencyError( "inconsistent search parameters - comp_class = %s, todo=%s" % (comp_class, todo) ) comp_filter = cdav.CompFilter("VTODO") comp_class = Todo if event is not None: if not event: raise NotImplementedError() if event: if comp_class is not None and comp_class is not Event: raise error.ConsistencyError( "inconsistent search parameters - comp_class = %s, event=%s" % (comp_class, event) ) comp_filter = cdav.CompFilter("VEVENT") comp_class = Event elif comp_class: if comp_class is Todo: comp_filter = cdav.CompFilter("VTODO") elif comp_class is Event: comp_filter = cdav.CompFilter("VEVENT") elif comp_class is Journal: comp_filter = cdav.CompFilter("VJOURNAL") else: raise error.ConsistencyError( "unsupported comp class %s for search" % comp_class ) if category is not None: filters.append(cdav.PropFilter("CATEGORIES") + cdav.TextMatch(category)) ## TODO: we probably need to do client side filtering. I would ## expect --category='e' to fetch anything having the category e, ## but not including all other categories containing the letter e. if class_ is not None: filters.append(cdav.PropFilter("CLASS") + cdav.TextMatch(class_)) for other in kwargs: if other in ( "uid", "summary", "comment", "description", "location", "status", ): filters.append( cdav.PropFilter(other.upper()) + cdav.TextMatch(kwargs[other]) ) else: raise NotImplementedError("searching for %s not supported yet" % other) if comp_filter and filters: comp_filter += filters vcalendar += comp_filter elif comp_filter: vcalendar += comp_filter elif filters: vcalendar += filters filter = cdav.Filter() + vcalendar root = cdav.CalendarQuery() + [prop, filter] return (root, comp_class) def freebusy_request(self, start, end): """ Search the calendar, but return only the free/busy information. Parameters: * start = datetime.today(). * end = same as above. Returns: * [FreeBusy(), ...] """ root = cdav.FreeBusyQuery() + [cdav.TimeRange(start, end)] response = self._query(root, 1, "report") return FreeBusy(self, response.raw) def todos( self, sort_keys=("due", "priority"), include_completed=False, sort_key=None ): """ fetches a list of todo events (refactored to a wrapper around search) Parameters: * sort_keys: use this field in the VTODO for sorting (iterable of lower case string, i.e. ('priority','due')). * include_completed: boolean - by default, only pending tasks are listed * sort_key: DEPRECATED, for backwards compatibility with version 0.4. """ if sort_key: sort_keys = (sort_key,) return self.search( todo=True, include_completed=include_completed, sort_keys=sort_keys ) def _calendar_comp_class_by_data(self, data): """ takes some data, either as icalendar text or icalender object (TODO: consider vobject) and returns the appropriate CalendarResourceObject child class. """ if data is None: ## no data received - we'd need to load it before we can know what ## class it really is. Assign the base class as for now. return CalendarObjectResource if hasattr(data, "split"): for line in data.split("\n"): line = line.strip() if line == "BEGIN:VEVENT": return Event if line == "BEGIN:VTODO": return Todo if line == "BEGIN:VJOURNAL": return Journal if line == "BEGIN:VFREEBUSY": return FreeBusy elif hasattr(data, "subcomponents"): if not len(data.subcomponents): return CalendarObjectResource ical2caldav = { icalendar.Event: Event, icalendar.Todo: Todo, icalendar.Journal: Journal, icalendar.FreeBusy: FreeBusy, } for sc in data.subcomponents: if sc.__class__ in ical2caldav: return ical2caldav[sc.__class__] return CalendarObjectResource def event_by_url(self, href, data=None): """ Returns the event with the given URL """ return Event(url=href, data=data, parent=self).load() def object_by_uid(self, uid, comp_filter=None, comp_class=None): """ Get one event from the calendar. Parameters: * uid: the event uid * comp_class: filter by component type (Event, Todo, Journal) * comp_filter: for backward compatibility Returns: * Event() or None """ if comp_filter: assert not comp_class if hasattr(comp_filter, "attributes"): comp_filter = comp_filter.attributes["name"] if comp_filter == "VTODO": comp_class = Todo elif comp_filter == "VJOURNAL": comp_class = Journal elif comp_filter == "VEVENT": comp_class = Event else: raise error.ConsistencyError("Wrong compfilter") query = cdav.TextMatch(uid) query = cdav.PropFilter("UID") + query root, comp_class = self.build_search_xml_query( comp_class=comp_class, filters=[query] ) try: items_found = self.search(root) if not items_found: raise error.NotFoundError("%s not found on server" % uid) except Exception as err: if comp_filter is not None: raise logging.warning( "Error %s from server when doing an object_by_uid(%s). search without compfilter set is not compatible with all server implementations, trying event_by_uid + todo_by_uid + journal_by_uid instead" % (str(err), uid) ) items_found = [] for compfilter in ("VTODO", "VEVENT", "VJOURNAL"): try: items_found.append( self.object_by_uid(uid, cdav.CompFilter(compfilter)) ) except error.NotFoundError: pass if len(items_found) >= 1: if len(items_found) > 1: logging.error( "multiple items found with same UID. Returning the first one" ) return items_found[0] # Ref Lucas Verney, we've actually done a substring search, if the # uid given in the query is short (i.e. just "0") we're likely to # get false positives back from the server, we need to do an extra # check that the uid is correct items_found2 = [] for item in items_found: ## In v0.10.0 we used regexps here - it's probably more optimized, ## but at one point it broke due to an extra CR in the data. ## Usage of the icalendar library increases readability and ## reliability item_uid = item.icalendar_component.get("UID", None) if item_uid and item_uid == uid: items_found2.append(item) if not items_found2: raise error.NotFoundError("%s not found on server" % uid) error.assert_(len(items_found2) == 1) return items_found2[0] def todo_by_uid(self, uid): return self.object_by_uid(uid, comp_filter=cdav.CompFilter("VTODO")) def event_by_uid(self, uid): return self.object_by_uid(uid, comp_filter=cdav.CompFilter("VEVENT")) def journal_by_uid(self, uid): return self.object_by_uid(uid, comp_filter=cdav.CompFilter("VJOURNAL")) # alias for backward compatibility event = event_by_uid def events(self): """ List all events from the calendar. Returns: * [Event(), ...] """ return self.search(comp_class=Event) def objects_by_sync_token(self, sync_token=None, load_objects=False): """objects_by_sync_token aka objects Do a sync-collection report, ref RFC 6578 and https://github.com/python-caldav/caldav/issues/87 This method will return all objects in the calendar if no sync_token is passed (the method should then be referred to as "objects"), or if the sync_token is unknown to the server. If a sync-token known by the server is passed, it will return objects that are added, deleted or modified since last time the sync-token was set. If load_objects is set to True, the objects will be loaded - otherwise empty CalendarObjectResource objects will be returned. This method will return a SynchronizableCalendarObjectCollection object, which is an iterable. """ cmd = dav.SyncCollection() token = dav.SyncToken(value=sync_token) level = dav.SyncLevel(value="1") props = dav.Prop() + dav.GetEtag() root = cmd + [level, token, props] (response, objects) = self._request_report_build_resultlist( root, props=[dav.GetEtag()], no_calendardata=True ) ## TODO: look more into this, I think sync_token should be directly available through response object try: sync_token = response.sync_token except: sync_token = response.tree.findall(".//" + dav.SyncToken.tag)[0].text ## this is not quite right - the etag we've fetched can already be outdated if load_objects: for obj in objects: try: obj.load() except error.NotFoundError: ## The object was deleted pass return SynchronizableCalendarObjectCollection( calendar=self, objects=objects, sync_token=sync_token ) objects = objects_by_sync_token def journals(self): """ List all journals from the calendar. Returns: * [Journal(), ...] """ return self.search(comp_class=Journal) class ScheduleMailbox(Calendar): """ RFC6638 defines an inbox and an outbox for handling event scheduling. TODO: As ScheduleMailboxes works a bit like calendars, I've chosen to inheritate the Calendar class, but this is a bit incorrect, a ScheduleMailbox is a collection, but not really a calendar. We should create a common base class for ScheduleMailbox and Calendar eventually. """ def __init__(self, client=None, principal=None, url=None): """ Will locate the mbox if no url is given """ super(ScheduleMailbox, self).__init__(client=client, url=url) self._items = None if not client and principal: self.client = principal.client if not principal and client: principal = self.client.principal if url is not None: self.url = client.url.join(URL.objectify(url)) else: self.url = principal.url try: self.url = self.client.url.join(URL(self.get_property(self.findprop()))) except: logging.error("something bad happened", exc_info=True) error.assert_(self.client.check_scheduling_support()) self.url = None raise error.NotFoundError( "principal has no %s. %s" % (str(self.findprop()), error.ERR_FRAGMENT) ) def get_items(self): """ TODO: work in progress TODO: perhaps this belongs to the super class? """ if not self._items: try: self._items = self.objects(load_objects=True) except: logging.debug( "caldav server does not seem to support a sync-token REPORT query on a scheduling mailbox" ) error.assert_("google" in str(self.url)) self._items = [ CalendarObjectResource(url=x[0], client=self.client) for x in self.children() ] for x in self._items: x.load() else: try: self._items.sync() except: self._items = [ CalendarObjectResource(url=x[0], client=self.client) for x in self.children() ] for x in self._items: x.load() return self._items ## TODO: work in progress # def get_invites(): # for item in self.get_items(): # if item.vobject_instance.vevent. class ScheduleInbox(ScheduleMailbox): findprop = cdav.ScheduleInboxURL class ScheduleOutbox(ScheduleMailbox): findprop = cdav.ScheduleOutboxURL class SynchronizableCalendarObjectCollection(object): """ This class may hold a cached snapshot of a calendar, and changes in the calendar can easily be copied over through the sync method. To create a SynchronizableCalendarObjectCollection object, use calendar.objects(load_objects=True) """ def __init__(self, calendar, objects, sync_token): self.calendar = calendar self.sync_token = sync_token self.objects = objects self._objects_by_url = None def __iter__(self): return self.objects.__iter__() def objects_by_url(self): """ returns a dict of the contents of the SynchronizableCalendarObjectCollection, URLs -> objects. """ if self._objects_by_url is None: self._objects_by_url = {} for obj in self: self._objects_by_url[obj.url] = obj return self._objects_by_url def sync(self): """ This method will contact the caldav server, request all changes from it, and sync up the collection """ updated_objs = [] deleted_objs = [] updates = self.calendar.objects_by_sync_token( self.sync_token, load_objects=False ) obu = self.objects_by_url() for obj in updates: if ( obj.url in obu and dav.GetEtag.tag in obu[obj.url].props and dav.GetEtag.tag in obj.props ): if obu[obj.url].props[dav.GetEtag.tag] == obj.props[dav.GetEtag.tag]: continue obu[obj.url] = obj try: obj.load() updated_objs.append(obj) except error.NotFoundError: deleted_objs.append(obj) obu.pop(obj.url) self.objects = obu.values() self.sync_token = updates.sync_token return (updated_objs, deleted_objs) class CalendarObjectResource(DAVObject): """ Ref RFC 4791, section 4.1, a "Calendar Object Resource" can be an event, a todo-item, a journal entry, or a free/busy entry """ _vobject_instance = None _icalendar_instance = None _data = None def __init__( self, client=None, url=None, data=None, parent=None, id=None, props=None ): """ CalendarObjectResource has an additional parameter for its constructor: * data = "...", vCal data for the event """ super(CalendarObjectResource, self).__init__( client=client, url=url, parent=parent, id=id, props=props ) if data is not None: self.data = data if id: old_id = self.icalendar_component.pop("UID", None) self.icalendar_component.add("UID", id) def add_organizer(self): """ goes via self.client, finds the principal, figures out the right attendee-format and adds an organizer line to the event """ principal = self.client.principal() ## TODO: remove Organizer-field, if exists ## TODO: what if walk returns more than one vevent? self.icalendar_component.add("organizer", principal.get_vcal_address()) def split_expanded(self): i = self.icalendar_instance.subcomponents tz_ = [x for x in i if isinstance(x, icalendar.Timezone)] ntz = [x for x in i if not isinstance(x, icalendar.Timezone)] if len(ntz) == 1: return [self] if tz_: error.assert_(len(tz_) == 1) ret = [] for ical_obj in ntz: obj = self.copy(keep_uid=True) obj.icalendar_instance.subcomponents = [] if tz_: obj.icalendar_instance.subcomponents.append(tz_[0]) obj.icalendar_instance.subcomponents.append(ical_obj) ret.append(obj) return ret def expand_rrule(self, start, end): """This method will transform the calendar content of the event and expand the calendar data from a "master copy" with RRULE set and into a "recurrence set" with RECURRENCE-ID set and no RRULE set. The main usage is for client-side expansion in case the calendar server does not support server-side expansion. It should be safe to save back to the server, the server should recognize it as recurrences and should not edit the "master copy". If doing a `self.load`, the calendar content will be replaced with the "master copy". However, as of 2022-10 there is no test code verifying this. :param event: Event :param start: datetime.datetime :param end: datetime.datetime """ import recurring_ical_events recurrings = recurring_ical_events.of(self.icalendar_instance).between( start, end ) recurrance_properties = ["exdate", "exrule", "rdate", "rrule"] # FIXME too much copying stripped_event = self.copy(keep_uid=True) # remove all recurrance properties for component in stripped_event.vobject_instance.components(): if component.name in ("VEVENT", "VTODO"): for key in recurrance_properties: try: del component.contents[key] except KeyError: pass calendar = self.icalendar_instance calendar.subcomponents = [] for occurance in recurrings: occurance.add("RECURRENCE-ID", occurance.get("DTSTART")) calendar.add_component(occurance) # add other components (except for the VEVENT itself and VTIMEZONE which is not allowed on occurance events) for component in stripped_event.icalendar_instance.subcomponents: if component.name not in ("VEVENT", "VTODO", "VTIMEZONE"): calendar.add_component(component) def set_relation( self, other, reltype=None, set_reverse=True ): ## TODO: logic to find and set siblings? """ Sets a relation between this object and another object (given by uid or object). """ ##TODO: test coverage reltype = reltype.upper() reltype_reverse = {"CHILD": "PARENT", "PARENT": "CHILD", "SIBLING": "SIBLING"}[ reltype ] if isinstance(other, CalendarObjectResource): if other.id: uid = other.id else: uid = other.icalendar_component["uid"] else: uid = other if set_reverse: other = self.parent.object_by_uid(uid) if set_reverse: other.set_relation(other=self, reltype=reltype_reverse, set_reverse=False) existing_relation = self.icalendar_component.get("related-to", None) existing_relations = ( existing_relation if isinstance(existing_relation, list) else [existing_relation] ) for rel in existing_relations: if rel == uid: return self.icalendar_component.add( "related-to", uid, parameters={"rel-type": reltype} ) def _get_icalendar_component(self, assert_one=True): """Returns the icalendar subcomponent - which should be an Event, Journal, Todo or FreeBusy from the icalendar class See also https://github.com/python-caldav/caldav/issues/232 """ ret = [ x for x in self.icalendar_instance.subcomponents if not isinstance(x, icalendar.Timezone) ] error.assert_(len(ret) == 1 or not assert_one) for x in ret: for cl in ( icalendar.Event, icalendar.Journal, icalendar.Todo, icalendar.FreeBusy, ): if isinstance(x, cl): return x error.assert_(False) def _set_icalendar_component(self, value): s = self.icalendar_instance.subcomponents i = [i for i in range(0, len(s)) if not isinstance(s[i], icalendar.Timezone)] if len(i) == 1: self.icalendar_instance.subcomponents[i[0]] = value else: my_instance = icalendar.Calendar() my_instance.add("prodid", "-//python-caldav//caldav//" + language) my_instance.add("version", "2.0") my_instance.add_component(value) self.icalendar_instance = my_instance icalendar_component = property( _get_icalendar_component, _set_icalendar_component, doc="icalendar component - cannot be used with recurrence sets", ) def add_attendee(self, attendee, no_default_parameters=False, **parameters): """ For the current (event/todo/journal), add an attendee. The attendee can be any of the following: * A principal * An email address prepended with "mailto:" * An email address without the "mailto:"-prefix * A two-item tuple containing a common name and an email address * (not supported, but planned: an ical text line starting with the word "ATTENDEE") Any number of attendee parameters can be given, those will be used as defaults unless no_default_parameters is set to True: partstat=NEEDS-ACTION cutype=UNKNOWN (unless a principal object is given) rsvp=TRUE role=REQ-PARTICIPANT schedule-agent is not set """ from icalendar import vCalAddress, vText if isinstance(attendee, Principal): attendee_obj = attendee.get_vcal_address() elif isinstance(attendee, vCalAddress): attendee_obj = attendee elif isinstance(attendee, tuple): if attendee[1].startswith("mailto:"): attendee_obj = vCalAddress(attendee[1]) else: attendee_obj = vCalAddress("mailto:" + attendee[1]) attendee_obj.params["cn"] = vText(attendee[0]) elif isinstance(attendee, str): if attendee.startswith("ATTENDEE"): raise NotImplementedError( "do we need to support this anyway? Should be trivial, but can't figure out how to do it with the icalendar.Event/vCalAddress objects right now" ) elif attendee.startswith("mailto:"): attendee_obj = vCalAddress(attendee) elif "@" in attendee and not ":" in attendee and not ";" in attendee: attendee_obj = vCalAddress("mailto:" + attendee) else: error.assert_(False) attendee_obj = vCalAddress() ## TODO: if possible, check that the attendee exists ## TODO: check that the attendee will not be duplicated in the event. if not no_default_parameters: ## Sensible defaults: attendee_obj.params["partstat"] = "NEEDS-ACTION" if not "cutype" in attendee_obj.params: attendee_obj.params["cutype"] = "UNKNOWN" attendee_obj.params["rsvp"] = "TRUE" attendee_obj.params["role"] = "REQ-PARTICIPANT" params = {} for key in parameters: new_key = key.replace("_", "-") if parameters[key] == True: params[new_key] = "TRUE" else: params[new_key] = parameters[key] attendee_obj.params.update(params) ievent = self.icalendar_component ievent.add("attendee", attendee_obj) def is_invite_request(self): if not self.data: self.load() return self.icalendar_instance.get("method", None) == "REQUEST" def accept_invite(self, calendar=None): self._reply_to_invite_request("ACCEPTED", calendar) def decline_invite(self, calendar=None): self._reply_to_invite_request("DECLINED", calendar) def tentatively_accept_invite(self, calendar=None): self._reply_to_invite_request("TENTATIVE", calendar) ## TODO: DELEGATED is also a valid option, and for vtodos the ## partstat can also be set to COMPLETED and IN-PROGRESS. def _reply_to_invite_request(self, partstat, calendar): error.assert_(self.is_invite_request()) if not calendar: calendar = self.client.principal().calendars()[0] ## we need to modify the icalendar code, update our own participant status self.icalendar_instance.pop("METHOD") self.change_attendee_status(partstat=partstat) self.get_property(cdav.ScheduleTag(), use_cached=True) try: calendar.save_event(self.data) except Exception as some_exception: ## TODO - TODO - TODO ## RFC6638 does not seem to be very clear (or ## perhaps I should read it more thoroughly) neither on ## how to handle conflicts, nor if the reply should be ## posted to the "outbox", saved back to the same url or ## sent to a calendar. self.load() self.get_property(cdav.ScheduleTag(), use_cached=False) outbox = self.client.principal().schedule_outbox() if calendar != outbox: self._reply_to_invite_request(partstat, calendar=outbox) else: self.save() def copy(self, keep_uid=False, new_parent=None): """ Events, todos etc can be copied within the same calendar, to another calendar or even to another caldav server """ obj = self.__class__( parent=new_parent or self.parent, data=self.data, id=self.id if keep_uid else str(uuid.uuid1()), ) if new_parent or not keep_uid: obj.url = obj.generate_url() else: obj.url = self.url return obj def load(self): """ Load the object from the caldav server. """ r = self.client.request(self.url) if r.status == 404: raise error.NotFoundError(errmsg(r)) self.data = vcal.fix(r.raw) if "Etag" in r.headers: self.props[dav.GetEtag.tag] = r.headers["Etag"] if "Schedule-Tag" in r.headers: self.props[cdav.ScheduleTag.tag] = r.headers["Schedule-Tag"] return self ## TODO: self.id should either always be available or never def _find_id_path(self, id=None, path=None): """ With CalDAV, every object has an URL. With icalendar, every object should have a UID. This UID may or may not be copied into self.id. This method will: 0) if ID is given, assume that as the UID, and set it in the object 1) if UID is given in the object, assume that as the ID 2) if ID is not given, but the path is given, generate the ID from the path 3) If neither ID nor path is given, use the uuid method to generate an ID (TODO: recommendation is to concat some timestamp, serial or random number and a domain) 4) if no path is given, generate the URL from the ID """ i = self._get_icalendar_component(assert_one=False) if not id and getattr(self, "id", None): id = self.id if not id: id = i.pop("UID", None) if not path and getattr(self, "path", None): path = self.path if id is None and path is not None and str(path).endswith(".ics"): id = re.search("(/|^)([^/]*).ics", str(path)).group(2) if id is None: id = str(uuid.uuid1()) i.pop("UID", None) i.add("UID", id) self.id = id for x in self.icalendar_instance.subcomponents: if not isinstance(x, icalendar.Timezone): error.assert_(x.get("UID", None) == self.id) if path is None: path = self.generate_url() else: path = self.parent.url.join(path) self.url = URL.objectify(path) def _put(self, retry_on_failure=True): ## SECURITY TODO: we should probably have a check here to verify that no such object exists already r = self.client.put( self.url, self.data, {"Content-Type": 'text/calendar; charset="utf-8"'} ) if r.status == 302: path = [x[1] for x in r.headers if x[0] == "location"][0] elif not (r.status in (204, 201)): if retry_on_failure: ## This looks like a noop, but the object may be "cleaned". ## See https://github.com/python-caldav/caldav/issues/43 self.vobject_instance return self._put(False) else: raise error.PutError(errmsg(r)) def _create(self, id=None, path=None, retry_on_failure=True): ## We're efficiently running the icalendar code through the icalendar ## library. This may cause data modifications and may "unfix" ## https://github.com/python-caldav/caldav/issues/43 self._find_id_path(id=id, path=path) self._put() def generate_url(self): ## See https://github.com/python-caldav/caldav/issues/143 for the rationale behind double-quoting slashes ## TODO: should try to wrap my head around issues that arises when id contains weird characters. maybe it's ## better to generate a new uuid here, particularly if id is in some unexpected format. if not self.id: self.id = self._get_icalendar_component(assert_one=False)["UID"] return self.parent.url.join(quote(self.id.replace("/", "%2F")) + ".ics") def change_attendee_status(self, attendee=None, **kwargs): if not attendee: attendee = self.client.principal() cnt = 0 if isinstance(attendee, Principal): for addr in attendee.calendar_user_address_set(): try: self.change_attendee_status(addr, **kwargs) ## TODO: can probably just return now cnt += 1 except error.NotFoundError: pass if not cnt: raise error.NotFoundError( "Principal %s is not invited to event" % str(attendee) ) error.assert_(cnt == 1) return ical_obj = self.icalendar_component attendee_lines = ical_obj["attendee"] if isinstance(attendee_lines, str): attendee_lines = [attendee_lines] strip_mailto = lambda x: str(x).replace("mailto:", "").lower() for attendee_line in attendee_lines: if strip_mailto(attendee_line) == strip_mailto(attendee): attendee_line.params.update(kwargs) cnt += 1 if not cnt: raise error.NotFoundError("Participant %s not found in attendee list") error.assert_(cnt == 1) def save( self, no_overwrite=False, no_create=False, obj_type=None, increase_seqno=True, if_schedule_tag_match=False, ): """ Save the object, can be used for creation and update. no_overwrite and no_create will check if the object exists. Those two are mutually exclusive. Some servers don't support searching for an object uid without explicitly specifying what kind of object it should be, hence obj_type can be passed. obj_type is only used in conjunction with no_overwrite and no_create. Returns: * self """ if ( self._vobject_instance is None and self._data is None and self._icalendar_instance is None ): return self path = self.url.path if self.url else None if no_overwrite or no_create: ## SECURITY TODO: path names on the server does not ## necessarily map cleanly to UUIDs. We need to do quite ## some refactoring here to ensure all corner cases are ## covered. Doing a GET first to check if the resource is ## found and then a PUT also gives a potential race ## condition. (Possibly the API gives no safe way to ensure ## a unique new calendar item is created to the server without ## overwriting old stuff or vice versa - it seems silly to me ## to do a PUT instead of POST when creating new data). ## TODO: the "find id"-logic is duplicated in _create, ## should be refactored if not self.id: for component in self.vobject_instance.getChildren(): if hasattr(component, "uid"): self.id = component.uid.value if not self.id and no_create: raise error.ConsistencyError("no_create flag was set, but no ID given") existing = None ## some servers require one to explicitly search for the right kind of object. ## todo: would arguably be nicer to verify the type of the object and take it from there if not self.id: methods = [] elif obj_type: methods = (getattr(self.parent, "%s_by_uid" % obj_type),) else: methods = ( self.parent.object_by_uid, self.parent.event_by_uid, self.parent.todo_by_uid, self.parent.journal_by_uid, ) for method in methods: try: existing = method(self.id) if no_overwrite: raise error.ConsistencyError( "no_overwrite flag was set, but object already exists" ) break except error.NotFoundError: pass if no_create and not existing: raise error.ConsistencyError( "no_create flag was set, but object does not exists" ) if increase_seqno and b"SEQUENCE" in to_wire(self.data): seqno = self.icalendar_component.pop("SEQUENCE", None) if seqno is not None: self.icalendar_component.add("SEQUENCE", seqno + 1) self._create(id=self.id, path=path) return self def __str__(self): return "%s: %s" % (self.__class__.__name__, self.url) ## implementation of the properties self.data, ## self.vobject_instance and self.icalendar_instance follows. The ## rule is that only one of them can be set at any time, this ## since vobject_instance and icalendar_instance are mutable, ## and any modification to those instances should apply def _set_data(self, data): ## The __init__ takes a data attribute, and it should be allowable to ## set it to an vobject object or an icalendar object, hence we should ## do type checking on the data (TODO: but should probably use ## isinstance rather than this kind of logic if type(data).__module__.startswith("vobject"): self._set_vobject_instance(data) return self if type(data).__module__.startswith("icalendar"): self._set_icalendar_instance(data) return self self._data = vcal.fix(data) self._vobject_instance = None self._icalendar_instance = None return self def _get_data(self): if self._data: return to_normal_str(self._data) elif self._vobject_instance: return to_normal_str(self._vobject_instance.serialize()) elif self._icalendar_instance: return to_normal_str(self._icalendar_instance.to_ical()) return None def _get_wire_data(self): if self._data: return to_wire(self._data) elif self._vobject_instance: return to_wire(self._vobject_instance.serialize()) elif self._icalendar_instance: return to_wire(self._icalendar_instance.to_ical()) return None data = property( _get_data, _set_data, doc="vCal representation of the object as normal string" ) wire_data = property( _get_wire_data, _set_data, doc="vCal representation of the object in wire format (UTF-8, CRLN)", ) def _set_vobject_instance(self, inst): self._vobject_instance = inst self._data = None self._icalendar_instance = None return self def _get_vobject_instance(self): if not self._vobject_instance: if self._get_data() is None: return None try: self._set_vobject_instance( vobject.readOne(to_unicode(self._get_data())) ) except: log.critical( "Something went wrong while loading icalendar data into the vobject class. ical url: " + str(self.url) ) raise return self._vobject_instance vobject_instance = property( _get_vobject_instance, _set_vobject_instance, doc="vobject instance of the object", ) def _set_icalendar_instance(self, inst): self._icalendar_instance = inst self._data = None self._vobject_instance = None return self def _get_icalendar_instance(self): if not self._icalendar_instance: if not self.data: return None self.icalendar_instance = icalendar.Calendar.from_ical( to_unicode(self.data) ) return self._icalendar_instance icalendar_instance = property( _get_icalendar_instance, _set_icalendar_instance, doc="icalendar instance of the object", ) ## for backward-compatibility - may be changed to ## icalendar_instance in version 1.0 instance = vobject_instance class Event(CalendarObjectResource): """ The `Event` object is used to represent an event (VEVENT). As of 2020-12 it adds nothing to the inheritated class. (I have frequently asked myself if we need those subclasses ... perhaps not) """ pass class Journal(CalendarObjectResource): """ The `Journal` object is used to represent a journal entry (VJOURNAL). As of 2020-12 it adds nothing to the inheritated class. (I have frequently asked myself if we need those subclasses ... perhaps not) """ pass class FreeBusy(CalendarObjectResource): """ The `FreeBusy` object is used to represent a freebusy response from the server. __init__ is overridden, as a FreeBusy response has no URL or ID. The inheritated methods .save and .load is moot and will probably throw errors (perhaps the class hierarchy should be rethought, to prevent the FreeBusy from inheritating moot methods) Update: With RFC6638 a freebusy object can have an URL and an ID. """ def __init__(self, parent, data, url=None, id=None): CalendarObjectResource.__init__( self, client=parent.client, url=url, data=data, parent=parent, id=id ) class Todo(CalendarObjectResource): """The `Todo` object is used to represent a todo item (VTODO). A Todo-object can be completed. Extra logic for different ways to complete one recurrence of a recurrent todo. Extra logic to handle due vs duration. """ def _next(self, ts=None, i=None, dtstart=None, rrule=None, by=None, no_count=True): """Special logic to fint the next DTSTART of a recurring just-completed task. If any BY*-parameters are present, assume the task should have fixed deadlines and preserve information from the previous dtstart. If no BY*-parametes are present, assume the frequency is meant to be the interval between the tasks. Examples: 1) Garbage collection happens every week on a Tuesday, but never earlier than 09 in the morning. Hence, it may be important to take out the thrash Monday evenings or Tuesday morning. DTSTART of the original task is set to Tuesday 2022-11-01T08:50, DUE to 09:00. 1A) Task is completed 07:50 on the 1st of November. Next DTSTART should be Tuesday the 7th of November at 08:50. 1B) Task is completed 09:15 on the 1st of November (which is probably OK, since they usually don't come before 09:30). Next DTSTART should be Tuesday the 7th of November at 08:50. 1C) Task is completed at the 5th of November. We've lost the DUE, but the calendar has no idea weather the DUE was a very hard due or not - and anyway, probably we'd like to do it again on Tuesday, so next DTSTART should be Tuesday the 7th of November at 08:50. 1D) Task is completed at the 7th of November at 07:50. Next DTSTART should be one hour later. Now, this is very silly, but an algorithm cannot do guesswork on weather it's silly or not. If DTSTART would be set to the earliest possible time one could start thinking on this task (like, Monday evening), then we would get TUe the 14th of November, which does make sense. Unfortunately the icalendar standard does not specify what should be used for DTSTART and DURATION/DUE. 1E) Task is completed on the 7th of November at 08:55. This efficiently means we've lost the 1st of November recurrence but have done the 7th of November recurrence instead, so next timestamp will be the 14th of November. 2) Floors at home should be cleaned like once a week, but there is no fixed deadline for it. For some people it may make sense to have a routine doing it i.e. every Tuesday, but this is not a strict requirement. If it wasn't done one Tuesday, it's probably even more important to do it Wednesday. If the floor was cleaned on a Saturday, it probably doesn't make sense cleaning it again on Tuesday, but it probably shouldn't wait until next Tuesday. Rrule is set to FREQ=WEEKLY, but without any BYDAY. The original VTODO is set up with DTSTART 16:00 on Tuesday the 1st of November and DUE 17:00. After 17:00 there will be dinner, so best to get it done before that. 2A) Floor cleaning was finished 14:30. The next recurrence has DTSTART set to 13:30 (and DUE set to 14:30). The idea here is that since the floor starts accumulating dirt right after 14:30, obviously it is overdue at 16:00 Tuesday the 7th. 2B) Floor cleaning was procrastinated with one day and finished Wednesday at 14:30. Next instance will be Wednesday in a week, at 14:30. 2C) Floor cleaning was procrastinated with two weeks and finished Tuesday the 14th at 14:30. Next instance will be Tuesday the 21st at 14:30. While scenario 2 is the most trivial to implement, it may not be the correct understanding of the RFC, and it may be tricky to get the RECURRENCE-ID set correctly. """ if not i: i = self.icalendar_component if not rrule: rrule = i["RRULE"] if not dtstart: if by is True or ( by is None and any((x for x in rrule if x.startswith("BY"))) ): if "DTSTART" in i: dtstart = i["DTSTART"].dt else: dtstart = ts or datetime.now() else: dtstart = ts or datetime.now() - self._get_duration(i) if not ts: ts = dtstart ## Counting is taken care of other places if no_count and "COUNT" in rrule: rrule = rrule.copy() rrule.pop("COUNT") rrule = rrulestr(rrule.to_ical().decode("utf-8"), dtstart=dtstart) return rrule.after(ts) def _reduce_count(self, i=None): if not i: i = self.icalendar_component if "COUNT" in i["RRULE"]: if i["RRULE"]["COUNT"][0] == 1: return False i["RRULE"]["COUNT"][0] -= 1 return True def _complete_recurring_safe(self, completion_timestamp): """This mode will create a new independent task which is marked as completed, and modify the existing recurring task. It is probably the most safe way to handle the completion of a recurrence of a recurring task, though the link between the completed task and the original task is lost. """ ## If count is one, then it is not really recurring if not self._reduce_count(): return self.complete(handle_rrule=False) next_dtstart = self._next(completion_timestamp) if not next_dtstart: return self.complete(handle_rrule=False) completed = self.copy() completed.url = self.parent.url.join(completed.id + ".ics") completed.icalendar_component.pop("RRULE") completed.save() completed.complete() duration = self.get_duration() i = self.icalendar_component i.pop("DTSTART", None) i.add("DTSTART", next_dtstart) self.set_duration(duration, movable_attr="DUE") self.save() def _complete_recurring_thisandfuture(self, completion_timestamp): """The RFC is not much helpful, a lot of guesswork is needed to consider what the "right thing" to do wrg of a completion of recurring tasks is ... but this is my shot at it. 1) The original, with rrule, will be kept as it is. The rrule string is fetched from the first subcomponent of the icalendar. 2) If there are multiple recurrence instances in subcomponents and the last one is marked with RANGE=THISANDFUTURE, then select this one. If it has the rrule property set, use this rrule rather than the original one. Drop the RANGE parameter. Calculate the next RECURRENCE-ID from the DTSTART of this object. Mark task as completed. Increase SEQUENCE. 3) Create a new recurrence instance with RANGE=THISANDFUTURE, without RRULE set (Ref https://github.com/Kozea/Radicale/issues/1264). Set the RECURRENCE-ID to the one calculated in #2. Calculate the DTSTART based on rrule and completion timestamp/date. """ recurrences = self.icalendar_instance.subcomponents orig = recurrences[0] if not "STATUS" in orig: orig["STATUS"] = "NEEDS-ACTION" if len(recurrences) == 1: ## We copy the original one just_completed = orig.copy() just_completed.pop("RRULE") just_completed.add( "RECURRENCE-ID", orig.get("DTSTART", completion_timestamp) ) seqno = just_completed.pop("SEQUENCE", 0) just_completed.add("SEQUENCE", seqno + 1) recurrences.append(just_completed) prev = recurrences[-1] rrule = prev.get("RRULE", orig["RRULE"]) thisandfuture = prev.copy() seqno = thisandfuture.pop("SEQUENCE", 0) thisandfuture.add("SEQUENCE", seqno + 1) ## If we have multiple recurrences, assume the last one is a THISANDFUTURE. ## (Otherwise, the data is coming from another client ...) ## The RANGE parameter needs to be removed if len(recurrences) > 2: if prev["RECURRENCE-ID"].params.get("RANGE", None) == "THISANDFUTURE": prev["RECURRENCE-ID"].params.pop("RANGE") else: raise NotImplementedError( "multiple instances found, but last one is not of type THISANDFUTURE, possibly this has been created by some incompatible client, but we should deal with it" ) self._complete_ical(prev, completion_timestamp) thisandfuture.pop("RECURRENCE-ID", None) thisandfuture.add("RECURRENCE-ID", self._next(i=prev, rrule=rrule)) thisandfuture["RECURRENCE-ID"].params["RANGE"] = "THISANDFUTURE" rrule2 = thisandfuture.pop("RRULE", None) ## Counting logic if rrule2 is not None: count = rrule2.get("COUNT", None) if count is not None and count[0] in (0, 1): for i in recurrences: self._complete_ical(i, completion_timestamp=completion_timestamp) thisandfuture.add("RRULE", rrule2) else: count = rrule.get("COUNT", None) if count is not None and count[0] <= len( [x for x in recurrences if not self._is_pending(x)] ): self._complete_ical( recurrences[0], completion_timestamp=completion_timestamp ) self.save(increase_seqno=False) return rrule = rrule2 or rrule duration = self._get_duration(i=prev) thisandfuture.pop("DTSTART", None) thisandfuture.pop("DUE", None) next_dtstart = self._next(i=prev, rrule=rrule, ts=completion_timestamp) thisandfuture.add("DTSTART", next_dtstart) self._set_duration(i=thisandfuture, duration=duration, movable_attr="DUE") self.icalendar_instance.subcomponents.append(thisandfuture) self.save(increase_seqno=False) def complete( self, completion_timestamp=None, handle_rrule=False, rrule_mode="safe" ): """Marks the task as completed. Parameters: * completion_timestamp - datetime object. Defaults to datetime.now(). * handle_rrule - if set to True, the library will try to be smart if the task is recurring. The default is False, for backward compatibility. I may consider making this one mandatory. * rrule_mode - The RFC leaves a lot of room for intepretation on how to handle recurring tasks, and what works on one server may break at another. The following modes are accepted: * this_and_future - see doc for _complete_recurring_thisandfuture for details * safe - see doc for _complete_recurring_safe for details """ if not completion_timestamp: completion_timestamp = datetime.utcnow().astimezone(vobject.icalendar.utc) if hasattr(self.instance.vtodo, "rrule") and handle_rrule: return getattr(self, "_complete_recurring_%s" % rrule_mode)( completion_timestamp ) self._complete_ical(completion_timestamp=completion_timestamp) self.save() def _complete_ical(self, i=None, completion_timestamp=None): ## my idea was to let self.complete call this one ... but self.complete ## should use vobject and not icalendar library due to backward compatibility. if i is None: i = self.icalendar_component assert self._is_pending(i) status = i.pop("STATUS", None) i.add("STATUS", "COMPLETED") i.add("COMPLETED", completion_timestamp) def _is_pending(self, i=None): if i is None: i = self.icalendar_component if i.get("COMPLETED", None) is not None: return False if i.get("STATUS", None) in ("NEEDS-ACTION", "IN-PROCESS"): return True if i.get("STATUS", None) in ("CANCELLED", "COMPLETED"): return False if not "STATUS" in i: return True ## input data does not conform to the RFC assert False def uncomplete(self): """Undo completion - marks a completed task as not completed""" ### TODO: needs test code for code coverage! ## (it has been tested through the calendar-cli test code) if not hasattr(self.vobject_instance.vtodo, "status"): self.vobject_instance.vtodo.add("status") self.vobject_instance.vtodo.status.value = "NEEDS-ACTION" if hasattr(self.vobject_instance.vtodo, "completed"): self.vobject_instance.vtodo.remove(self.vobject_instance.vtodo.completed) self.save() def get_duration(self): """According to the RFC, either DURATION or DUE should be set for a task, but never both - implicitly meaning that DURATION is the difference between DTSTART and DUE (personally I believe that's stupid. If a task takes five minutes to complete - say, fill in some simple form that should be delivered before midnight at new years eve, then it feels natural for me to define "duration" as five minutes, DTSTART to "some days before new years eve" and DUE to 20xx-01-01 00:00:00 - but I digress. This method will return DURATION if set, otherwise the difference between DUE and DTSTART (if both of them are set). Arguably, this logic belongs to the icalendar/vobject layer as it has nothing to do with the caldav protocol. TODO: should be fixed for Event class as well (only difference is that DTEND is used rather than DUE) and possibly also for Journal (defaults to one day, probably?) """ i = self.icalendar_component return self._get_duration(i) def _get_duration(self, i): if "DURATION" in i: return i["DURATION"].dt elif "DTSTART" in i and "DUE" in i: return i["DUE"].dt - i["DTSTART"].dt else: return timedelta(0) def set_duration(self, duration, movable_attr="DTSTART"): """ If DTSTART and DUE is already set, one of them should be moved. Which one? I believe that for EVENTS, the DTSTART should remain constant and DTEND should be moved, but for a task, I think the due date may be a hard deadline, hence by default we'll move DTSTART. TODO: can this be written in a better/shorter way? """ i = self.icalendar_component return self._set_duration(i, duration, movable_attr) def _set_duration(self, i, duration, movable_attr="DTSTART"): if ("DUE" in i or "DURATION" in i) and "DTSTART" in i: i.pop(movable_attr, None) if movable_attr == "DUE": i.pop("DURATION", None) if movable_attr == "DTSTART": i.add("DTSTART", i["DUE"].dt - duration) elif movable_attr == "DUE": i.add("DUE", i["DTSTART"].dt + duration) elif "DUE" in i: i.add("DTSTART", i["DUE"].dt - duration) elif "DTSTART" in i: i.add("DUE", i["DTSTART"].dt + duration) else: if "DURATION" in i: i.pop("DURATION") i.add("DURATION", duration) def get_due(self): """ A VTODO may have due or duration set. Return or calculate due. """ i = self.icalendar_component if "DUE" in i: return i["DUE"].dt elif "DURATION" in i and "DTSTART" in i: return i["DTSTART"].dt + i["DURATION"].dt else: return None def set_due(self, due, move_dtstart=False): """The RFC specifies that a VTODO cannot have both due and duration, so when setting due, the duration field must be evicted """ i = self.icalendar_component duration = self.get_duration() i.pop("DURATION", None) i.pop("DUE", None) if move_dtstart and duration and "DTSTART" in i: i.pop("DTSTART") i.add("DTSTART", due - duration) i.add("DUE", due) caldav-0.11.0/changelog-0.10.md000066400000000000000000000155521433711056000156600ustar00rootroot00000000000000# Changelog v0.9.2 -> v0.10 ## Warning v0.10 does introduce some "bugfixes" and refactorings which are supposed to be harmless and which haven't caused any breakages in tests - but I cannot vouch for that it will not have unintended side effects in your environment. If you're using the caldav library for production-critical tasks, you may want to hang on for a while before upgrading, or wait for v0.10.1. ## Quick summary * Work on a universal search method * Refactoring, consolidated lots of slightly duplicated code into one method to rule them all * Support for things needed by the calendar-cli utility, like search by categories * Support for completion of recurring tasks * More utilities for tasks * Uncomplete-method ... for undoing the complete (recurrences not supported though) * get/set duration/dtstart/dtend (arguably this belongs to vobject and/or icalendar) * Other improvements: * picklable URLs * display_name convenience method * possible to set child/parent relationships * Potential bugfix: sequence number may need to be increased when saving something to the calendar (not backported, this may have side effects) ## Search method Calendar now has a method search. Here is some information from the docstring: Parameters supported: * xml - use this search query, and ignore other filter parameters * comp_class - set to event, todo or journal to restrict search to this resource type. Some server implementations require this to be set. * todo - sets comp_class to Todo, and restricts search to pending tasks, unless the next parameter is set ... * include_completed - include completed tasks * event - sets comp_class to event * text attribute search parameters: category, uid, summary, omment, description, location, status * expand - do server side expanding of recurring events/tasks * start, stop: do a time range search * filters - other kind of filters (in lxml tree format) * sort_keys - list of attributes to use when sorting not supported yet: * negated text match * attribute not set ## Completed tasks While the RFCs do support recurring tasks, they are not very clear on the details. In v0.10 there are three different ways to complete a task. The first one is to ignore the RRULE property and mark the task as completed. This is the backwards-compatibility mode - though, according to my understanding of a "recurring task" this is the wrong way to do it. The two other modes considers the task to be "interval based" is no BY-rules are specified in the RRULE - meaning that if a task is supposed to be done weekly, then a week should pass from it was completed and until one needs to start with it again - no matter the DTSTART of the original instance - but the standards may also be interpreted so that if the original task was to be started at a Tuesday 10:00, then all recurrences should be started at a Tuesday 10:00. Both the modes stores a copy of the completed task, for the record. The "safe" mode stores the copy as a completely independent task, and modifies the DTSTART/DUE of the original task - so the completed task is not linked up to the recurring task. (One may eventually try to make a link by establishing a "parent task"). The "thisandfuture"-mode will establish the completed task as a separate recurrence in a recurrence set. The non-completed task is also duplicated with a new DTSTART set and range set to THISANDFUTURE. As I understand the RFC, this is the way to handle interval-based tasks, future recurrences will then base their starting time on the DTSTART of the THISANDFUTURE task. For fixed tasks the THISANDFUTURE recurrence is moot, so I'm considering to create a third mode as well. ## Github issues and pull requests https://github.com/python-caldav/caldav/issues/16 https://github.com/python-caldav/caldav/issues/43 https://github.com/python-caldav/caldav/issues/127 https://github.com/python-caldav/caldav/issues/145 https://github.com/python-caldav/caldav/issues/160 https://github.com/python-caldav/caldav/pull/204 https://github.com/python-caldav/caldav/pull/208 https://github.com/python-caldav/caldav/pull/212 https://github.com/python-caldav/caldav/pull/216 https://github.com/python-caldav/caldav/issues/219 ## Commits 8d9a36d004d983b2423e8d33d756ecbc0022e8c5 - allow requests timeout (Marcel Schwarz) 2ac6cb7a4c256e6f42316336d66aef4a8f100867 - make URLs pickable (Ryan Nowakowski) b4464cd57f696a783ef7c90ee9640c2f5ee8408b - style fixup 1f007a443393ea74b1ecb811464b7c8706051496 - get_display_name convenience method (mc-borscht) 1d639eb94fe902d3fa86a3558154264267cd384b - get_display_name revisited 57fdf04a6019f8d811e0f1b7c3b5ecfd21ed44ce - style fixup 25e9efae98746374e4e5753db2eb957b3f2a8f82 - test code dea8eb50e86a9c36dcd1ca8b46b71eebec036437 - style fixup 2ed9b993a4fdcfaf8199a8a1301b2a4fb5b6fa03 - test code a853c03956d59533d566ed696848e69924d4d207 - search method 62531ead3ad95a95c0b944f4fcfae9700d843b42 - changelog 485561d1f46d1de9460fb9262294ea48b7b3f0e0 - test code (plus some compatibility fixes, already backported to v0.9) 0c6ccfff623a3e53c05f1d91e4fcc4be64fbe4bf - changelog 9044c90e35908a353c872d8fb97729e9f162f588 - test code 6572d93b6d167c72ba1c533b8505c0969f3d1b0f - search method 05b22708d8a84835b818efcbdba0b4fdeff52b32 - fixup 994c572f958ef8575921c701a20e0443384927ae - test code b8358adb1686cfabfe7f90e342f09fc3ea53facb - test code 3cfaacdb10ed0f1ce48b288d3c49e4c9b4ef7b74 - test code 24e340074f1341f3ffea64dd05ed87f659e78cb4 - test code 96d65509c2f0f431a81af7128c974450d84d9cd4 - style fixup 5c2cf8129a23a2eac468c5937d29ec42f88dc059 - undo complete 84c4d03135f78b63cf005e529e28b7c304f36015 - style fixup e60de3b84b36e279f8723eb7c8c1ddb7fa6e3f49 - parent/child relationships cd3c07472bc96ce3dde5a40fa0752da86a1d7559 - recurring complete 2148891187c1083258edd371fc63026daf33a357 - fixup recurring complete b21a2c6fe5a081bee29ec1e238d2c75235e51f41 - fixup recurring complete 24e024f0576308bd631f0b5ccd06d3007682cc7e - style fixup ec15e2762b23232705deb5ce43a0b4693acf1f3b - fixup recurring complete 534d0fde36a3cd5b6eaa1692458f124cdbb64f16 - style fixup e4b38ef9197a78c5ca2ca518c2e01b54fc58b0eb - fixup for search 20c2a293ee8cb227dc6ff1be0faeddd62a999b15 - style fixup e723d88af9337d0af5772214a8c4d7f150943a8e - test code e9c45819a74c6fa3a775f8fcb6e797b0a4839711 - sequence number to be increased 65920b592220e70561e33beadbbd17028e5b6e65 - style fixup 452d0df5f93c26575ff0a35ffe0e81dc26923bc4 - test code d51c5f9c51a04a6872ebc5c15cffbd7018d7393b - icalendar is now an official dependency b5bd38e944f563a547b693c676b8800734b3ec7a - style fixup (or breakdown) 7232e69972950d092f1fdda4779234ce206d5da0 - docfix ca8da73ce4691ff095ce8a7a974ca1c64f3d2380 - changelog 94a39c3d2871b105b291025af80548f876b4ee4c - changelog 81e7fcdce03239cbb7505b062d5274f56d97763a - bugfixes and refactorings 86e85f9f470f78098e00ed7fc9e479cab3856cb3 - style fixup 6360f30405bcfaecf5fc256c91dcdf317bcaa0d0 - bugfixes and refactorings 6711a2904b9fe57585455246709799925ea0cde5 - test code caldav-0.11.0/changelog-0.11.md000066400000000000000000000151231433711056000156530ustar00rootroot00000000000000# Changelog v0.10 -> v0.11 Version 0.10 and below are considered to be "end of life". There will not be a maintenance branch for v0.10. ## Warning v0.10 and v0.11 does introduce some "bugfixes" and refactorings which are supposed to be harmless and which haven't caused any breakages in tests - but I cannot vouch for that it will not have unintended side effects in your environment. If you're using the caldav library for production-critical tasks, you may want to hang on for a while before upgrading, or wait for v0.11.1. Version 0.11 contains minor changes that may break backward compatibility (according to the SemVer specification backward incompatible changes are allowed when doing 0.x-releases. Anyway, according to my knowledge this is the first time a release contained things breaking backward-compatibility. The return from the search method has changed a bit, I think I can do this because v0.10 hasn't been out for long, hence most likely most users will be using `calendar.date_search()` rather than `calendar.search()` for doing timerange searches, and because the change is relatively harmless and unlikely to break things. The return from the data property is now enforced to be a normal string with unix linebreaks, this is more likely to cause problems, but the previous behaviour was unpredictable and would anyway sooner or later cause problems for people depending on the return type to be a binary or being with carriage returns). ## Summary * Daniele Ricci has made support for client-side expanding, intended for the calendar servers that supports recurrences but not server-side expanding. * For expanded recurrences, the `search`-method will (by default) deliver each recurrence as a separate object (i.e. caldav.Event). **This is slightly backward incompatible with v0.10**. * Now `obj.data` will always return an ordinary string with ordinary line breaks, while `obj.wire_data` will always return a byte string with CRLN line endings. **This may break thinsg if the client expects a binary return, or depends on carriage returns in the output**. While the return type of `obj.data` has been slightly unpredictable, it may still have been deterministic dependent on usage pattern - so the caller may have gotten some expectations which may now be broken. * Bugfixes, some of the new code in v0.10 didn't handle icalendar data containing a timezone. Some other minor bugfixes. ## Client-side expanding, and splitting of recurrence sets I will use the word "event" below. All code also works for tasks, but it's mainly events that are relevant to expand. * The CalendarObjectResource class now has a method expand_rrule which will convert it from a recurring event to a recurrence set. * There is also a new split_expanded method. For a recurrence set, it will return a list of Event objects. For an ordinary event, it will return a list object containing self (and only self). * There was already some code in place in date_search raising errors or logging errors when the server didn't expand things as expected. This is now replaced with the new expand-logic (and consolidated into the new search method). * For the new search method, the default behaviour is to split an expanded recurrence set into separate objects. For instance, if there is two objects in the calendar, one recurring daily task and a one-off event that will happen on Wednesday, then calendar.date_search(start=Monday, end=Sunday) will return two Event objects, with one of them having len(self.icalendar_instance.subcomponents)==5. The calendar.search(start=Monday, end=Sunday, event=True) will return six Event objects, each with one subcomponent (which may be accessed through event.icalendar_object() - or event.icalendar_component in a future release, ref https://github.com/python-caldav/caldav/issues/232) * The new search method can also deliver the recurrence set as one object, by passing the "split_expanded" attribute. ## Other CalendarObjectResource._calendar_object() has been made into a public property self.calendar_component. This may be a very useful helper - event.icalendar_instance is a calendar object which may hold a lot of components, typically it contains only one component, to find the component noe would have to dig through event.icalendar_instance.subcomponents - which may also contain timezone components. ## Deprecations * There is a method build_date_search_query which was used internally, but unfortunately without the underscore prefix. I don't expect anyone is using it, and it will be removed in some future version. * The date_search method is widely used and won't be removed any time soon - but it's redundant, consider using search instead. Though, procrastinating dealing with doc and examples ... https://github.com/python-caldav/caldav/issues/233 * The date_search has a verify_expand attribute (because some Swede thought it was a great idea to throw an assert if the server didn't support expand). By now it's moot, as we're doing client side expandation instead. The attribute does nothing, but is kept there for backward compatibility. ## New dependencies * The icalendar library was an optional requirement in v0.10 - but it's being used several places now and has become a normal requirement. * The recurring_ical_events library is used for client-side expansion and has been added to the requirements. ## Test code * A minor change in how things work with the new version of icalendar caused the tests to break. This has been mended. * All tests exercising the date_search method now also exercises the search method (and then I refactored the date_search method to be a thinnest possible wrapper over search) * Done some work to get rid of warnings from pytest * Excersising the client-side expansion for servers that does not support server-side expansion, but do support recurrences, and which do not throw errors when asked to expand. ## Github issues and pull requests https://github.com/python-caldav/caldav/issues/232 - Create an CalendarObjectResource.icalendar_component property https://github.com/python-caldav/caldav/issues/157 - Better support for broken caldav servers: do client-side parsing of rrules on expanded date searches https://github.com/python-caldav/caldav/issues/230 - test_create_ical fails on v0.10 https://github.com/python-caldav/caldav/pull/229 - Remove nose dependency https://github.com/python-caldav/caldav/pull/223 - obj.icalendar_instance.subcomponents[0] cannot be trusted ## Commits ... too many of them. I will try harder to make better commits, perhaps put the master branch in protected mode, pull-requests only (but that's moot if I forget to squash the pull request before/when merging ...) caldav-0.11.0/changelog-0.8.md000066400000000000000000000276161433711056000156130ustar00rootroot00000000000000# Changelog v0.8 -> v0.8.1 ## Various bugfixes and minor enhancements * urllib.quote on uuids before including it in a new URL on object creation * Principal.calendar (and CalendarSet.calendar) now accepts an URL as "cal_id" (this is a regression fix - it did work back in 0.5.0, but not in 0.8.0). * Minor workaround for problem with cloud.global/e.email: urllib.quote the calendar_home_set_url if it's a path and it contains the character '@' * Minor fixes to improve support for SOGo * Bugfix for non-ascii in the password * timezones ... the API of tzlocal was changed for a short while, pytz is deprecated, but icalendar only supports timestamps localized with pytz. What a mess! * Added supported-report-set to the tags (but so far no code for using it) * Multiget didn't work * Silly spelling mistake in one of the error classes * Attempts to resolve broken ical issues with X-APPLE-STRUCTURED-EVENT and X-APPLE-STRUCTURED-LOCATION (#37, ...) * Compatibility issue with e.email Commits: ca12e15fadfd49dffcf01119e1b227f568fadf70, 5196ee7d64eae6c70d3cd602d40b55525400380e, 7b81cbc54237e2c1c33072329bc2359d0ef61e5f, 87510930a89fe9f8098346b356b4412ce35610f5, 4db75faf67b7355c89ada1119865b6dfc1d783c4, 17ce149635c0a4d44015d60a2d5362dec28d521c, b9c33ed9a5be83e94a7824fcfc0185048c397993, c9af8f598150af25cbb1e0716c845ac95c512c89, f30b574d099688760fe4ff6ee7ee13a5f82c5f08 dea36c80e4fc0e6a8be1f38f93ed17efc8733bfe d4991907462a0afe446939dbf64f4bcb11e58368 53ac8dcdd6b611e3448dab0ea101b1f83d66aeb2 57bf87ab123c2f7693ae68c659b68232d6000afd 72e303264d7347a2d9bdf6dddb618eb72b747d56 13a4714432fe0a2aebf4618a2830f66b16462a48 Github issues: https://github.com/python-caldav/caldav/issues/146, https://github.com/python-caldav/caldav/issues/148 https://github.com/python-caldav/caldav/issues/150 https://github.com/python-caldav/caldav/issues/37 https://github.com/python-caldav/caldav/issues/151 https://github.com/python-caldav/caldav/issues/142 https://github.com/python-caldav/caldav/issues/143 https://github.com/python-caldav/caldav/issues/153 Credits: Paul Waite, @brainsky, @TabError, @Friedi1970, Vincent Brillaut, Michael Thies, @l0ga, Raimund Schlüßler ## Documentation * Quite some minor changes in docstrings and comments in the code * Some minor tweaks at the documentation * Comments in the usage example that updating an event through `calendar.save_event(event.data)` may break. * Bugfix in the example code Commits: 35f72d37e12d6e8e966ee0873d0ba055c961def4 2549c1056ffce60b0c2919c5a13fd600e180b0de f5d3dafcd0b1e324557c50ef984868a15e28c04c 4f560d61ac5365f9794249ca3a71a88f607dcfbc 3451bcc0dae8686bcb95628e22d51aef5da92ef0 92c50898b5e00d963522640fa07c26261437442f 285bda15c8b4f6853c078fba084581096b2e052a b9c33ed9a5be83e94a7824fcfc0185048c397993 7b81cbc54237e2c1c33072329bc2359d0ef61e5f 9232a2d724ce7a4e1117447c62087413de8857d8 b3c13f079851a7ae413a08ee0ea4d08e8a265dac 53ac8dcdd6b611e3448dab0ea101b1f83d66aeb2 8ccf97d13bb6a2b92ffcc7709e249085e387ffc9 5826f5409c140129c2025dc6f4dfa38b06c11f0c afd95f0ac58d80c88ea001586b3dd4576e356e1a ## RFC6638 Version 0.8.0 was supposed to support RFC6638 fully, work on it was started for a client. but due to lack of support at the server side the work was halted prior to the release - hence version 0.8.0 contains some poorly tested support for RFC6638. I tried to do some more work on it after the 0.8.0-release, but ran out of time. I'm not sure when I will get the time to do more work on this - but at least 0.8.1 fixes some very silly things, like removing an occurrence of `pdb.set_trace()` in the middle of one of the methods ... (should probably add a pre-commit hook to avoid that in the future). Commits: b9c33ed9a5be83e94a7824fcfc0185048c397993 ## Tests Testing framework accepts even more "compatibility flags". SOGo and nextcloud have been tested. Some test code has been added to excersise the scheduling, but it's a bit difficult to test since it's needed with a server supporting RFC6638 and having three test users. It's needed with a Xandikos version less than 2.0.4 to get the automated tests to run (I should look into that). Commits: b9c33ed9a5be83e94a7824fcfc0185048c397993, 704a1d6571c3a9b5b2c5d58af948a238620c1019 72e303264d7347a2d9bdf6dddb618eb72b747d56 Credits: @Sigmun, Michael Thies ## I have no idea what I'm doing (But don't worry, it's mostly just me arguing with the sfinx documentation and the auto-generated doc at readthedocs.org, no modifications to the code itself) Commits: 35f72d37e12d6e8e966ee0873d0ba055c961def4 2549c1056ffce60b0c2919c5a13fd600e180b0de 641959b228288826dbe20ed665057ce1e7a14096 fa91c623009ff6a8d1e35bb2eee1cbbee01c7770 4f560d61ac5365f9794249ca3a71a88f607dcfbc 92c50898b5e00d963522640fa07c26261437442f 2285ab0a204da3f9dce4e896d8ba37a8b75ca053 Anti-credits: @tobixen # Changelog v0.7.1 -> v0.8 This is a replacement of the old ChangeLog file, admittedly in a non-standard format. I believe it's more essential to group the changes by purpose than by date/file modified, and I think it's important to have references to the issues in the issue tracker and commit IDs. Author is changed to "credits", emails are left out (though it's probably moot since those are publically available in the git repository anyway), and I'm skipping crediting myself. ## Support for WebDAV sync-tokens Github issues: https://github.com/python-caldav/caldav/issues/87, https://github.com/python-caldav/caldav/issues/122 https://github.com/python-caldav/caldav/issues/122, https://github.com/python-caldav/caldav/issues/142 Commits: 35a49ae860df1e8e7c6cec554d34ff6bc4a0c2dd, 11768b9b5aee24278b2a60209d3550933720374d, cec96c51bf2a770bd041e8db3896425f3ab997cd, bc138c55a7e85e4411cc4614f5f7da6f7ae97a36, d5c17b522bef2e62038528f609fe320e91720f87, c838a30f4c5aa343ec27c78571907e6963732403, bc589093a34f0ed0ef489ad5e9cba048750c9837 Credits: Nylas ## Improved support for iCloud, Google Apple seems to have more or less abandoned their position in the CalDAV ecosystem. While they've never said officially that iCloud supports CalDAV, they do support some basic CalDAV operations. There hasn't been done much dedicated work on supporting iCloud in this release, but a lot of testing has been done, some few tweaks, and some documentation. Google has two APIs, one legacy API and one new API. The new API is not supported yet. As with iCloud, while very little dedicated work has been done to support Google, I've done a lot of testing, some few tweaks, and some documentation. Unlike Apple, Google is very transparent on their (lack of proper) CalDAV support. Github issues: https://github.com/python-caldav/caldav/issues/3, https://github.com/python-caldav/caldav/issues/119 Commits: d50c2d2db8cec19911ae1857032dca16ada18c58, e129185e74d9d600113aaad0997e09e564dcfd89, c3d4d405240392c07d84f2e432bd696ccc6901f0, f4733ea37b4f1251d24b48cbc20022f4b11c5ab9 Credits: Nylas ## Work on support for RFC6635, scheduling/RVSP (partially done) Convenience-methods for finding CalDAV inbox and outbox, for accepting calendar invites, for adding invitations to an icalendar object, doing freebusy-requests towards email addresses and misc. Should be ready for use, but it's relatively untested. Commits: 1e8ee44e9b11051fdba7414a325956338f543a6b, 2238d093aea6c04b0cdb2c877c6fde45ef0118a7, 0f4cdb8bec113212b4901e367a79542c35572991, 32722bf3b8337a4839396f21406f441199a6b02c, Github issues: https://github.com/python-caldav/caldav/issues/125 Credits: Nylas ## Multiget (partially done) Method for doing a multiget for fetching multiple events in one http request has been added, but more work is needed (multiget should be utilized by the library when applicable without the end-user having to be explicit on it, it's missing test code). Github issues: https://github.com/python-caldav/caldav/issues/115, https://github.com/python-caldav/caldav/pull/111 Commits: ed89a5911e1e9ba38302fef5febc5f03906f84bd Credits: Mincheol Song (@mtorange) ## Documentation improvements Github issues: https://github.com/python-caldav/caldav/issues/120, https://github.com/tobixen/calendar-cli/issues/82, https://github.com/python-caldav/caldav/pull/135, https://github.com/python-caldav/caldav/pull/108, https://github.com/python-caldav/caldav/issues/107 Commits: ce2e2b701cf80718679800de647df285d401a4c8, bc138c55a7e85e4411cc4614f5f7da6f7ae97a36, 048d6be742178d238956172837ca01a57252ddc4, 48a790cd0fce42855e240c39f219b111511b6dcd, 2dfcdeca570877d33297bacf40fc805c32f75708, 8940ecaf405eb5f955e9ebf032775edc16b9ce19, 3ca4eaf99e4e83253e60356ef408c2bdf3703628 Credits: @olf42, @tfinke, Teymour Aldridge (@teymour-aldridge), @VanKurt ## Improved calendar API * Possible to look up a calendar by name * Possible to access a calendar by url from the DAVClient object * New method calendar.get_supported_components() Github issues: https://github.com/python-caldav/caldav/issues/101 https://github.com/python-caldav/caldav/pull/17, https://github.com/python-caldav/caldav/issues/114, https://github.com/python-caldav/caldav/issues/134, https://github.com/python-caldav/caldav/issues/124 Commits: 37769cfa21670e9c547f2bf877baee835de39cc7, 3754d13270a5326a595c7ad290ebdf003f6d96b6, 285f83e1cf484ff727d540c91e19aa5bff02ed31, 3d6be14bce5d15cd3437103c4738782fcd5b91bf Credits: Ian Bottomley (@kyloe), Michael Wieland (@Programie) ## HTTP improvements Usage of requests.Session()-objects may speed up the http communication by allowing HTTP keepalive and pooling. By now it's also possible to pass ssl_cert in the connection parameters, for proper verification of self-signed certificates. Github issues: https://github.com/python-caldav/caldav/pull/137, https://github.com/python-caldav/caldav/pull/110, https://github.com/python-caldav/caldav/pull/105 Commits: 917b17633d76f947c1778defa55ba680625b8fe4, 17ce1955ee6c233320a32cd61d24b9d9f3781e86, 6aa26c3bd497d6d6c0c3e6cceda4d02e25f31c74 Credits: Herve Commowick (@vr), Jelmer Vernooij (@jelmer), Stephan (@kiffie) ## Various bugfixes Github issues: https://github.com/python-caldav/caldav/issues/112 https://github.com/python-caldav/caldav/issues/133 Commits: ed89a5911e1e9ba38302fef5febc5f03906f84bd, 3d0666d332d6505761488a04324c11257b7ed532, 576fd176c3ef64db973f059000976b7cc8c97d8c, Credits: Mincheol Song (@mtorange), @pleasedonotwatch, @frank-pet ## Refactoring work Some major refactoring work has been done. I've been consolidating lots of similar-looking code in previous releases, but it has been sort of "cargo-cult copying", I never bothered to really understand the lxml.etree module, nor to dig deeper into the details in the XML communication going on. Basic XML response parsing has now been moved to the DAVResponse class, the response parsing should be a little bit easier to understand and debug, a little bit more robust, and I also made the API for fetching properties simpler to understand. A lot of testing has been done, the "pure" unit test has been split out to a separate file, this includes lots of XML response snippets observed from the various server implementations and expectations on how they should be parsed. Github issues: https://github.com/python-caldav/caldav/issues/118 https://github.com/python-caldav/caldav/issues/121 Commits: 3754d13270a5326a595c7ad290ebdf003f6d96b6, d5c17b522bef2e62038528f609fe320e91720f87, bc589093a34f0ed0ef489ad5e9cba048750c9837, 98a73ae2f948ca70d3425d5aeb52afff63d0def6, 552ff4728a191610d08f31a181573fb1f57e8692 e5968d0faa6852440f27ea23778a96814bef95fd 60ec379725b3ffedf57f33e869b15a4abf09464d, 951b878d44fa2d1d11bd1a5dd9b56d2f57b0179a, 02e5aa9358f65534077fa6e4c72d112faa05adb6 ## Improvements on the test framework Github issues: https://github.com/python-caldav/caldav/issues/136, https://github.com/python-caldav/caldav/issues/117 https://github.com/python-caldav/caldav/issues/2 Commits: 9dceb43c9abb32e98c948b49caf73eb24ae9d56f, 3ee4e42e2fa8f78b71e5ffd1ef322e4007df7a60, bc589093a34f0ed0ef489ad5e9cba048750c9837, 98a73ae2f948ca70d3425d5aeb52afff63d0def6 e5968d0faa6852440f27ea23778a96814bef95fd, 62b160aa39d260cd2ecf7ca6e2fb84454ebd2575 610fe1ccae88ec614f08081b3ae884734636fb35, 471c0741ca13c3e4006104db6fa52c5acd6515d8 Credits: @frank-pet caldav-0.11.0/changelog-0.9.md000066400000000000000000000253131433711056000156040ustar00rootroot00000000000000# Changelog v0.9.1 -> v0.9.2 ## Quick summary Some compatibility-fixes and other minor bugfixes have been cherry-picked from the master branch to make a v0.9.2-release. Work in progress on a v0.10-release featuring a search method. v0.9.2 particularly includes fixes and workarounds supporting the server implementations Synology and Robur. Éloi Rivard has done a lot of work rewamping the test framework, those changes have also been backported to the 0.9.2-branch. He also introduced the black code style, that changeset also had to be backported to allow other changesets to be cherry-picked without problems. Commits included from master branch: c19abb303dc915cd8471628ef0a4f034204d1b5b - changelog updates 633d028c6cb26c1bea8ae0f3c275ead5e98a32c9 - changes to test framework (Éloi Rivard) f147e821cd5facb1baf8e94f9cb3920e07411552 - changes to test framework (Éloi Rivard) 77093494d81445847b7c64606f4a4647572a0350 - support for multiple authentication methods server headers (compatibility fix) (Éloi Rivard) 6b791de7ccae48901ada8318158ae824f8f36982 - running test framework from github (Éloi Rivard) 31b1618f3672633a69ee81b991a8c80063fd76a6 - changes to test framework (Éloi Rivard) 73675ffdba833ecfa3d093beb306c72e4076abff - build documentation with tox (Éloi Rivard) 5d697243230d1a27c774bb82f8c4030cb0dd7cd1 - tox style (Éloi Rivard) 958afe546fbe2c8857ae9043d5cbc4de544fa3dc - enforcing black style (Éloi Rivard) 425c99a64693c39722d13b47bb35dc4e40162517 - documenting a gotcha 661785b875522f7e51f7a339f797bea8df7c88f7 - fixing a problem with the tests 63f07378a36ab11b1e14fb97ef4fd0576e15a2b9 - fixing a problem with the tests (Jelmer Vernooij) 7a77c1e6ce323702cf51595785970dc91bde8c70 - workaround for Synology compatbility 00c9195c83989b6337be34be0f229f62600dd3d3 - fix for Synology compatbility 7adf7a4cfeb36ecf35bc7f3ebc473032f4ca0811 - more Synology compatibility fixing 9766b8989617ded545e6e13310597da2a3bc166f - fix for robur calendar server 48a389357a76f2a2e2809c548788b1ca0c914d96 - test code for the latter 5fbc73ceb997203e001ca321c997fd5ed9d5e3f5 - reverting some automated style 25e9efae98746374e4e5753db2eb957b3f2a8f82 - fix for the test code dea8eb50e86a9c36dcd1ca8b46b71eebec036437 - black style (fixup ^) 2ed9b993a4fdcfaf8199a8a1301b2a4fb5b6fa03 - fix for the test code 62531ead3ad95a95c0b944f4fcfae9700d843b42 - changelog 485561d1f46d1de9460fb9262294ea48b7b3f0e0 - robur compatibility fixand test code. Only parts of the commit backported f0f8bf1417efa7a69d6e2fbc6122fd240a231fcd - fix for test code for robur 52dbf48756f4fb7969c0d81e38ba8c9092ea7145 - fix for test code for robur github issues and pull requests: https://github.com/python-caldav/caldav/issues/191 https://github.com/python-caldav/caldav/issues/196 https://github.com/python-caldav/caldav/pull/199 https://github.com/python-caldav/caldav/pull/198 https://github.com/python-caldav/caldav/pull/197 https://github.com/python-caldav/caldav/pull/195 https://github.com/python-caldav/caldav/pull/194 https://github.com/python-caldav/caldav/pull/193 https://github.com/python-caldav/caldav/issues/200 https://github.com/python-caldav/caldav/issues/205 https://github.com/python-caldav/caldav/pull/210 https://github.com/python-caldav/caldav/pull/211 https://github.com/python-caldav/caldav/issues/213 https://github.com/python-caldav/caldav/pull/214 https://github.com/python-caldav/caldav/issues/215 Credits: Éloi Rivard, Jelmer Vernooij, Rick Jeanes, Andreas Dominik Preikschat, @devmount # Changelog v0.9.0 -> v0.9.1 ## Quick summary * Tweaks to support the DAVMail server implementation * v0.9.0 broke for python 3.5 and lower, due to usage of fstrings. v0.9.1 has been tested with python 3.5. * Discovery of some cases of broken support of recurring events on the server side, and logging of errors (optionally raising of errors) * New method .close on the DAVClient object. ## Minor changes * Quite some users are complaining because recurring events aren't supported - which is intentional, because the server is supposed to take care of that. Unfortunately quite some servers doesn't. Thanks to cos, at least we now have some code in place to log an error (or optionally raising an error) when recurrences aren't taken care of (though, it only works if the server is returning non-expanded recurring events - if searching for a recurrence and the server doesn't find it, then ... no error logged). The error message is referring to https://github.com/python-caldav/caldav/issues/157 * New method `.close` on the DAVClient object Credits: |cos|, neonfighter28 commits: 53c74737fd83b32e016a954b7b5f57bb028e0f24 c20ed6a65acae6c4e1cdd0fa2b9dc73244932681 ddcd11508290b0dbc580dde0f2aa712d95d1e6f7 99494d160e5363dd3e29cbbea9889521665b64a4 0ce851d0baedea70069df780e525a7aafda5aa5c ## Documentation fixes * Added the fastmail caldav URL to the documentation - including note that they are picky on the trailing slash - ref https://github.com/home-assistant/core/issues/66599 * Keeping the changelog up-to-date commits: ec29395beb27dfa734078195b29685563c284cbc ea4fb0845343436fd5f4cb65852ee1437505ae58 fcb616d7f6016d65bc79dbae7f131d049fa1522d d43fbdde507a642dd5a55facc6bbea4b372e786b 71ddf6ae0e4ad4802b9315356a46fd116b7cb682 credits: Martin Eberhardt ## Bugfixes * v0.9.0 broke on elder python versions due to an f"string". The f-format was introduced in python 3.6. Anything below is actually End of Life versions, but still ... it's a very small effort here to preserve compatibility with elder python versions. * The library had some difficulties with the DAVMail server - at one point it does not return a calendar home set property, and at another point the calendar home set URL is the same as the calendar URL. * The `URL.canonical()` method should rewrite variants of the same URL into something that will be equal - some work has been put making it more robust. Credits: |cos|, Jonathan Neuhauser Commits: a82cb81d02fe207106951cdecd49fefc8146155a 1ab5b9926c372af8f5644908d523e3b47fa3f9c1 2aae381f2cb499f203a994d217ce989a8d97071e 0e224be5818b2392402d6e5cc593d94655eb06d0 e5b5fa2c829d0d79f42adb45c5f2ce4aba7d9496 e5b5fa2c829d0d79f42adb45c5f2ce4aba7d9496 9f8786acd6cce506235da80b9d8438c6354e91bc 8dd2094471bac3cd17f3f9fb5570afd1ffdf1729 49f70bcb245aac8411e5c8942e91145342257fa3 Issues: https://github.com/python-caldav/caldav/issues/189. f-strings discussed outside github. ## Linting Removal of some extra white space Credits: |cos| commits: 783e233cf8c86fb87fef0245841edd86e392efb3 ## Testing framework and incompatibility matrix * The testTodoDatesearch is pesky - because every server has different visions on how (or weather) to support recurring tasks. Added yet more complexity to allow the test code to pass on different servers. * Removed some silly test code in testSetCalendarProperties - seems to be just recreating a calendar, which we do in the setup of any test unless the `unique_calendar_ids`-flag is given. commits 7bdb32498582477a8b8bd45a871d441711356903 a82b3b42a62a5e8972bdb41d64748695dc95ff85 9f67d5b349796a131d37da266eb6b7b305402087 # Changelog v0.8.2 -> v0.9 ## API changes `save_todo`, `save_event` and `save_journal` now takes extra parameters, assumed to be equivalent with ical attributes as defined in the icalendar library, and may build icalendar data from scratch or enhance on the given icalendar data. Added a context manager, so one can do `with DAVClient(foo) as client: ...` Github issues: https://github.com/python-caldav/caldav/issues/156 https://github.com/python-caldav/caldav/issues/155 https://github.com/python-caldav/caldav/issues/175 Commits: eb8b7f877f4c5ca6181a177431b4a57f0a8c2039 b32f3ef3e15cd5edacca0ddaa9240c3814bc88ad fe108599167a517c56411d0bac9abb3abae8e825 ae2e71b1f0 Credits: @Sigmun @neonfighter28 ## Refactoring The digest vs basic auth is solved a bit differently in 0.8.2 and 0.9. It has been fixed very carefully but inelegantly in 0.8.2, 0.9 contains a complete rewrite. It was later shown that the logic in 0.8.2 broke for some servers, hence I've decided to discontinue support for the 0.8-branch. Github issues: https://github.com/python-caldav/caldav/issues/158 Commits: 1366e4e503180e10696f99ede6c2526451c7acab b3bde1c0e79d850acd5fa0615d3fbf6a3289c148 6be182800bbf7367a8da1005dad4b3e0b43967ca 164f88d ## Bugfixes and test framework This release does not fix a reported regression at https://github.com/home-assistant/core/issues/65588 (and probably some other places) that iCloud caldav URLs pointing directly to a calendar won't work. I'm not sure if this is a regression in the caldav library or in Home Assistant. I've written up test code to catch this issue, but didn't have an iCloud account available to test with while releasing. This release does not fix a reported possible regression in Home Assistant that public ICS feeds does not work anymore as the "caldav URL". I don't think such an URL ever was working with the caldav library, I believe it's needed with some extra logic in the Home Assistant module if public ics feeds are to be supported. (issues https://github.com/home-assistant/core/issues/70205 https://github.com/home-assistant/core/issues/65941) * Quite some problems fixed with the authentication code * The string representation of any error class was hardcoded as "AuthorizationError". * Concatinating an empty unicode string with an empty byte string will cause an exception. The python_utilities.to_wire method would return an empty unicode string if given an empty unicode string. * the flags no_overwrite and no_create in save_todo and save_journal didn't work * scheduling still doesn't work very well, but one bug has been fixed * tests and compatibility lists: some tweaks to let tests pass on the test servers (including fastmail) * tests: make sure to delete the test calendar properly * tests: test that non-base-urls still work * tests: working around some issues on xandrikos startup, allows newer xandrikos version to be used * tests: added flag "enable" in the test server config file Github issues: https://github.com/python-caldav/caldav/issues/163 https://github.com/python-caldav/caldav/issues/164 https://github.com/python-caldav/caldav/issues/165 https://github.com/python-caldav/caldav/issues/166 https://github.com/python-caldav/caldav/issues/168 https://github.com/python-caldav/caldav/issues/169 https://github.com/python-caldav/caldav/issues/171 https://github.com/python-caldav/caldav/pull/176 https://github.com/python-caldav/caldav/pull/178 https://github.com/home-assistant/core/issues/67330 https://github.com/home-assistant/core/issues/71048 https://github.com/home-assistant/core/issues/65804 Commits: eb708a9 232acdd 509b4f01 67e47bc 29e2dd3 bafa810 dd26017 1de95ce1f ce89561bf 9aa31802 872232 52870b10 fa55194457a6f4 266a822e77 ce7c20527034f1 53da5d86c9cb 1d63ea77 4628bbc Credits: Bjoern Kahl, Markus Behrens, Michael Thingnes caldav-0.11.0/docs/000077500000000000000000000000001433711056000137535ustar00rootroot00000000000000caldav-0.11.0/docs/Makefile000066400000000000000000000061101433711056000154110ustar00rootroot00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source .PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " changes to make an overview of all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: -rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/python-caldav.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/python-caldav.qhc" latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ "run these through (pdf)latex." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." caldav-0.11.0/docs/make.bat000066400000000000000000000060211433711056000153570ustar00rootroot00000000000000@ECHO OFF REM Command file for Sphinx documentation set SPHINXBUILD=sphinx-build set BUILDDIR=build set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source if NOT "%PAPER%" == "" ( set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% ) if "%1" == "" goto help if "%1" == "help" ( :help echo.Please use `make ^` where ^ is one of echo. html to make standalone HTML files echo. dirhtml to make HTML files named index.html in directories echo. pickle to make pickle files echo. json to make JSON files echo. htmlhelp to make HTML files and a HTML help project echo. qthelp to make HTML files and a qthelp project echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter echo. changes to make an overview over all changed/added/deprecated items echo. linkcheck to check all external links for integrity echo. doctest to run all doctests embedded in the documentation if enabled goto end ) if "%1" == "clean" ( for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i del /q /s %BUILDDIR%\* goto end ) if "%1" == "html" ( %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html echo. echo.Build finished. The HTML pages are in %BUILDDIR%/html. goto end ) if "%1" == "dirhtml" ( %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml echo. echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. goto end ) if "%1" == "pickle" ( %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle echo. echo.Build finished; now you can process the pickle files. goto end ) if "%1" == "json" ( %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json echo. echo.Build finished; now you can process the JSON files. goto end ) if "%1" == "htmlhelp" ( %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp echo. echo.Build finished; now you can run HTML Help Workshop with the ^ .hhp project file in %BUILDDIR%/htmlhelp. goto end ) if "%1" == "qthelp" ( %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp echo. echo.Build finished; now you can run "qcollectiongenerator" with the ^ .qhcp project file in %BUILDDIR%/qthelp, like this: echo.^> qcollectiongenerator %BUILDDIR%\qthelp\python-caldav.qhcp echo.To view the help file: echo.^> assistant -collectionFile %BUILDDIR%\qthelp\python-caldav.ghc goto end ) if "%1" == "latex" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex echo. echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. goto end ) if "%1" == "changes" ( %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes echo. echo.The overview file is in %BUILDDIR%/changes. goto end ) if "%1" == "linkcheck" ( %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck echo. echo.Link check complete; look for any errors in the above output ^ or in %BUILDDIR%/linkcheck/output.txt. goto end ) if "%1" == "doctest" ( %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest echo. echo.Testing of doctests in the sources finished, look at the ^ results in %BUILDDIR%/doctest/output.txt. goto end ) :end caldav-0.11.0/docs/source/000077500000000000000000000000001433711056000152535ustar00rootroot00000000000000caldav-0.11.0/docs/source/caldav/000077500000000000000000000000001433711056000165055ustar00rootroot00000000000000caldav-0.11.0/docs/source/caldav/davclient.rst000066400000000000000000000002711433711056000212100ustar00rootroot00000000000000:mod:`DAVClient` -- A simple DAV client ======================================= .. automodule:: caldav.davclient :synopsis: Class for storing server connection details :members: caldav-0.11.0/docs/source/caldav/objects.rst000066400000000000000000000002321433711056000206650ustar00rootroot00000000000000:mod:`objects` -- Object definitions ==================================== .. automodule:: caldav.objects :synopsis: Base DAVObject class :members: caldav-0.11.0/docs/source/conf.py000066400000000000000000000145751433711056000165660ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # python-caldav documentation build configuration file, created by # sphinx-quickstart on Thu Jun 3 10:47:52 2010. # # This file is execfile()d with the current directory set to its containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import os import sys # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.append(os.path.abspath("../../")) from setup import version # -- General configuration ----------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = ["sphinx.ext.autodoc", "sphinx.ext.coverage", "sphinx.ext.doctest"] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # The suffix of source filenames. source_suffix = ".rst" # The encoding of source files. # source_encoding = 'utf-8' # The master toctree document. master_doc = "index" # General information about the project. project = "caldav" copyright = "2010-2021, Cyril Robert, Tobias Brox and other contributors" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. # version = '0' # The full version, including alpha/beta/rc tags. release = version # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # today = '' # Else, today_fmt is used as the format for a strftime call. # today_fmt = '%B %d, %Y' # List of documents that shouldn't be included in the build. # unused_docs = [] # List of directories, relative to source directory, that shouldn't be searched # for source files. exclude_trees = [] # The reST default role (used for this markup: `text`) to use for all documents. # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. Major themes that come with # Sphinx are currently 'default' and 'sphinxdoc'. html_theme = "default" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". # html_title = None # A shorter title for the navigation bar. Default is the same as html_title. # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". # html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. # html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. # html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. # html_additional_pages = {} # If false, no module index is generated. # html_use_modindex = True # If false, no index is generated. # html_use_index = True # If true, the index is split into individual pages for each letter. # html_split_index = False # If true, links to the reST sources are added to the pages. # html_show_sourcelink = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # html_use_opensearch = '' # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = '' # Output file base name for HTML help builder. htmlhelp_basename = "python-caldavdoc" # -- Options for LaTeX output -------------------------------------------------- # The paper size ('letter' or 'a4'). # latex_paper_size = 'letter' # The font size ('10pt', '11pt' or '12pt'). # latex_font_size = '10pt' # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ ( "index", "python-caldav.tex", "python-caldav Documentation", "Cyril Robert", "manual", ), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # latex_use_parts = False # Additional stuff for the LaTeX preamble. # latex_preamble = '' # Documents to append as an appendix to all manuals. # latex_appendices = [] # If false, no module index is generated. # latex_use_modindex = True caldav-0.11.0/docs/source/index.rst000066400000000000000000000361561433711056000171270ustar00rootroot00000000000000================================= Documentation: caldav |release| ================================= .. module:: caldav Contents ======== .. toctree:: :maxdepth: 1 caldav/davclient caldav/objects Project home ============ The project currently lives on github, https://github.com/python-caldav/caldav - if you have problems using the library (including problems understanding the documentation), please feel free to report it on the issue tracker there. Objective and scope =================== The python caldav library should make interactions with calendar servers simple and easy. Simple operations (like find a list of all calendars owned, inserting a new event into a calendar, do a simple date search, etc) should be trivial to accomplish even if the end-user of the library has no or very little knowledge of the caldav, webdav or icalendar standards. The library should be agile enough to allow "power users" to do more advanced stuff. RFC 4791, 2518, 5545, 6638 et al -------------------------------- RFC 4791 (CalDAV) outlines the standard way of communicating with a calendar server. RFC 4791 is an extension of RFC 4918 (WebDAV). The scope of this library is basically to cover RFC 4791/4918, the actual communication with the caldav server. (The WebDAV standard also has quite some extensions, this library supports some of the relevant extensions as well). There exists another library webdavclient3 for handling RFC 4918 (WebDAV), ideally we should be depending on it rather than overlap it. RFC 6638/RFC 6047 is extending the CalDAV and iCalendar protocols for scheduling purposes, work is in progress to support RFC 6638. Support for RFC 6047 is considered mostly outside the scope of this library, though for convenience this library may contain methods like accept() on a calendar invite (which involves fetching the invite from the server, editing the calendar data and putting it to the server). This library should make it trivial to fetch an event, modify the date and save it back to the server - but to do that it's also needed to support RFC 5545 (icalendar). It's outside the scope of this library to implement logic for parsing and modifying RFC 5545, instead we depend on another library for that. RFC 5545 describes the icalendar format. Constructing or parsing icalendar data was considered out of the scope of this library, but we do make exceptions - like, there is a method to complete a task - it involves editing the icalendar data, and now the `save_event`, `save_todo` and `save_journal` methods are able to construct icalendar data if needed. There exists two libraries supporting RFC 5545, vobject and icalendar. The icalendar library seems to be more popular. Version 0.x depends on vobject, version 1.x will depend on icalendar. Version 0.7 and higher supports both, but the "alternative" library will only be loaded when/if needed, and the vobject support may be deprecated in the future. Misbehaving server implementations ---------------------------------- Some server implementations may have some "caldav"-support that either doesn't implement all of RFC 4791, breaks the standard a bit, or has extra features. As long as it doesn't add too much complexity to the code, hacks and workarounds for "badly behaving caldav servers" are considered to be within the scope. Ideally, users of the caldav library should be able to download all the data from one calendar server or cloud provider, upload it to another server type or cloud provider, and continue using the library without noticing any differences. To get there, it may be needed to add tweaks in the library covering the things the servers are doing wrong. There exists an extention to the standard covering calendar color and calendar order, allegedly with an xml namespace ``http://apple.com/ns/ical/``. That URL gives (301 https and then) 404. I've so far found no documentation at all on this extension - however, it seems to be supported by several caldav libraries, clients and servers. As of 0.7, calendar colors and order is available for "power users". Quickstart ========== All code examples below was snippets from the basic_usage_examples.py, but the documentation and the examples may have drifted apart (TODO: does there exist some good system for this? Just use docstrings and doctests?) Setting up a caldav client object and a principal object: .. code-block:: python with caldav.DAVClient(url=url, username=username, password=password) as client: my_principal = client.principal() ... Note that if a .netrc file exists, it will be honored and the username and password may be omitted. (Known bug: .netrc will be honoed even if username and password is given - ref https://github.com/python-caldav/caldav/issues/206) Fetching calendars: .. code-block:: python calendars = my_principal.calendars() Creating a calendar: .. code-block:: python my_new_calendar = my_principal.make_calendar(name="Test calendar") Adding an event to the calendar, v0.9 adds this interface: .. code-block:: python my_event = my_new_calendar.save_event( dtstart=datetime.datetime(2020,5,17,8), dtend=datetime.datetime(2020,5,18,1), summary="Do the needful", rrule={'FREQ': 'YEARLY')) Adding an event described through some ical text: .. code-block:: python my_event = my_new_calendar.save_event("""BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VEVENT UID:20200516T060000Z-123401@example.com DTSTAMP:20200516T060000Z DTSTART:20200517T060000Z DTEND:20200517T230000Z RRULE:FREQ=YEARLY SUMMARY:Do the needful END:VEVENT END:VCALENDAR """) Do a date search in a calendar: .. code-block:: python events_fetched = my_new_calendar.date_search( start=datetime(2021, 1, 1), end=datetime(2024, 1, 1), expand=True) To modify an event: event.vobject_instance.vevent.summary.value = 'Norwegian national day celebrations' event.save() .. code-block:: python event.icalendar_instance is also supported. Find a calendar with a known URL without going through the Principal-object: .. code-block:: python the_same_calendar = client.calendar(url=my_new_calendar.url) Get all events from a calendar: .. code-block:: python all_events = the_same_calendar.events() Deleting a calendar (or, basically, any object): .. code-block:: python my_new_calendar.delete() Create a task list: .. code-block:: python my_new_tasklist = my_principal.make_calendar( name="Test tasklist", supported_calendar_component_set=['VTODO']) Adding a task to a task list. The ics parameter may be some complete ical text string or a fragment. .. code-block:: python my_new_tasklist.save_todo( ics = "RRULE:FREQ=YEARLY", summary="Deliver some data to the Tax authorities", dtstart=date(2020, 4, 1), due=date(2020,5,1), categories=['family', 'finance'], status='NEEDS-ACTION') Fetching tasks: .. code-block:: python todos = my_new_tasklist.todos() Date_search also works on task lists, but one has to be explicit to get the tasks: .. code-block:: python todos = my_new_calendar.date_search( start=datetime(2021, 1, 1), end=datetime(2024, 1, 1), compfilter='VTODO', expand=True) Mark a task as completed: .. code-block:: python todos[0].complete() More examples ============= Check the examples folder, particularly `basic examples `_. There is also a `scheduling examples `_ for sending, receiving and replying to invites, though this is not very well-tested so far. The `test code `_ also covers lots of stuff, though it's not much optimized for readability (at least not as of 2020-05). Tobias Brox is also working on a `command line interface `_ built around the caldav library. Notable classes and workflow ============================ * You'd always start by initiating a :class:`caldav.davclient.DAVClient` object, this object holds the authentication details for the server. * From the client object one can get hold of a :class:`caldav.objects.Principal` object representing the logged-in principal. * From the principal object one can fetch / generate :class:`caldav.objects.Calendar` objects. * From the calendar object one can fetch / generate :class:`caldav.objects.Event` objects and :class:`caldav.objects.Todo` objects (as well as :class:`caldav.objects.Journal` objects - does anyone use Journal objects?). Eventually the library may also spew out objects of the base class (:class:`caldav.objects.CalendarObjectResource`) if the object type is unknown when the object is instantiated. * If one happens to know the URLs, objects like calendars, principals and events can be instantiated without going through the Principal-object of the logged-in user. A path, relative URL or full URL should work, but the URL should be without authentication details. For convenience, the classes above are also available as :class:`caldav.DAVClient`, :class:`caldav.Principal`, :class:`caldav.Calendar`, :class:`caldav.Event`, :class:`caldav.Todo` etc. Compatibility ============= (This will probably never be completely up-to-date. CalDAV-servers tend to be a moving target, and I rarely recheck if things works in newer versions of the software after I find an incompatibility) The test suite is regularly run against several calendar servers, see https://github.com/python-caldav/caldav/issues/45 for the latest updates. See ``tests/compatibility_issues.py`` for the most up-to-date list of compatibility issues. In early versions of this library test breakages was often an indication that the library did not conform well enough to the standards, but as of today it mostly indicates that the servers does not support the standard well enough. It may be an option to add tweaks to the library code to cover some of the missing functionality. Here are some known issues: * iCloud, Google and Zimbra are notoriously bad on their CalDAV-support. * You may want to avoid non-ASCII characters in the calendar name, or some servers (at least Zimbra) may behave a bit unexpectedly. * It's non-trivial to fix proper support for recurring events and tasks on the server side. DAViCal and Baikal are the only one I know of that does it right, all other calendar implementations that I've tested fails (but in different ways) on the tests covering recurrent events and tasks. Xandikos developer claims that it should work, I should probably revisit it again. * Baikal does not support date search for todo tasks. DAViCal has slightly broken support for such date search. * There are some special hacks both in the code and the tests to work around compatibility issues in Zimbra (this should be solved differently) * Not all servers supports task lists, not all servers supports freebusy, and not all servers supports journals. Xandikos and Baikal seems to support them all. * Calendar creation is actually not a mandatory feature according to the RFC, but the tests depends on it. The google calendar does support creating calendars, but not through their CalDAV adapter. * iCloud may be a bit tricky, this is tracked in issue https://github.com/python-caldav/caldav/issues/3 - the list of incompatibilities found includes: * No support for freebusy-requests, tasks or journals (only support for basic events). * Broken (or no) support for recurring events * We've observed information reappearing even if it has been deleted (i.e. recreating a calendar with the same name as a deleted calendar, and finding that the old events are still there) * Seems impossible to have the same event on two calendars * Some problems observed with the propfind method * object_by_uid does not work (and my object_by_uid follows the example in the RFC) * Google seems to be the new Microsoft, according to the issue tracker it seems like their CalDAV-support is rather lacking. At least they have a list ... https://developers.google.com/calendar/caldav/v2/guide * radicale will auto-create a calendar if one tries to access a calendar that does not exist. The normal method of accessing a list of the calendars owned by the user seems to fail. Some notes on Caldav URLs ========================= CalDAV URLs can be quite confusing, some software requires the URL to the calendar, other requires the URL to the principal. The Python CalDAV library does support accessing calendars and principals using such URLs, but the recommended practice is to configure up the CalDAV root URL and tell the library to find the principal and calendars from that. Typical examples of CalDAV URLs: * iCloud: ``https://caldav.icloud.com/``. Note that there is no template for finding the calendar URL and principal URL for iCloud - such URLs contains some ID numbers, by simply sticking to the recommended practice the caldav library will find those URLs. A typical icloud calendar URL looks like ``https://p12-caldav.icloud.com/12345/calendars/CALNAME``. * Google: ``https://www.google.com/calendar/dav/`` - but this is a legacy URL, before using the officially supported URL https://github.com/python-caldav/caldav/issues/119 has to be resolved. There are some details on the new CalDAV endpoints at https://developers.google.com/calendar/caldav/v2/guide. The legacy calendar URL for the primary personal calendar seems to be of the format ``https://www.google.com/calendar/dav/donald%40gmail.com/events``. When creating new calendars, they seem to end up under a global namespace. * DAViCal: The caldav URL typically seems to be on the format ``https://your.server.example.com/caldav.php/``, though it depends on how the web server is configured. The primary calendars have URLs like ``https://your.server.example.com/caldav.php/donald/calendar`` and other calendars have names like ``https://your.server.example.com/caldav.php/donald/golfing_calendar``. * Zimbra: The caldav URL is typically on the format ``https://mail.example.com/dav/``, calendar URLs can be on the format ``https://mail.example.com/dav/donald@example.com/My%20Golfing%20Calendar``. Display name always matches the last part of the URL. * Fastmail: ``https://caldav.fastmail.com/dav/`` - note that the trailing dash is significant (ref https://github.com/home-assistant/core/issues/66599) Unit testing ============ To start the tests code, install everything from the setup.tests_requires list and run: .. code-block:: bash $ python setup.py test (tox should also work, but it may be needed to look more into it) It will run some unit tests and some functional tests. You may want to add your own private servers into tests/conf_private.py, see tests/conf_private.py.EXAMPLE Documentation ============= To build the documentation, install sphinx and run: .. code-block:: bash $ python setup.py build_sphinx License ======= Caldav is dual-licensed under the GNU GENERAL PUBLIC LICENSE Version 3 and the Apache License 2.0. ==================== Indices and tables ==================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` caldav-0.11.0/examples/000077500000000000000000000000001433711056000146415ustar00rootroot00000000000000caldav-0.11.0/examples/basic_usage_examples.py000066400000000000000000000163141433711056000213630ustar00rootroot00000000000000import sys from datetime import date from datetime import datetime ## We'll try to use the local caldav library, not the system-installed sys.path.insert(0, "..") sys.path.insert(0, ".") import caldav ## DO NOT name your file calendar.py or caldav.py! We've had several ## issues filed, things break because the wrong files are imported. ## It's not a bug with the caldav library per se. ## CONFIGURATION. Edit here, or set up something in ## tests/conf_private.py (see tests/conf_private.py.EXAMPLE). caldav_url = "https://calendar.example.com/dav" username = "somebody" password = "hunter2" ## When using the caldav library, one should always start off with initiating a ## DAVClient object, which should contain connection details and credentials. ## Initiating the object does not cause any requests to the server, so this ## will not break even if caldav url is set to example.com client = caldav.DAVClient(url=caldav_url, username=username, password=password) ## For the convenience, if things are correctly set up in test config, ## the code below may replace the client object with one that works. if "example.com" in caldav_url and password == "hunter2": from tests.conf import client as client_ client = client_() ## Typically the next step is to fetch a principal object. ## This will cause communication with the server. my_principal = client.principal() ## The principals calendars can be fetched like this: calendars = my_principal.calendars() if calendars: ## Some calendar servers will include all calendars you have ## access to in this list, and not only the calendars owned by ## this principal. print("your principal has %i calendars:" % len(calendars)) for c in calendars: print(" Name: %-20s URL: %s" % (c.name, c.url)) else: print("your principal has no calendars") ## Let's try to find or create a calendar ... try: ## This will raise a NotFoundError if calendar does not exist my_new_calendar = my_principal.calendar(name="Test calendar") assert my_new_calendar ## calendar did exist, probably it was made on an earlier run ## of this script except caldav.error.NotFoundError: ## Let's create a calendar my_new_calendar = my_principal.make_calendar(name="Test calendar") ## Let's add an event to our newly created calendar ## (This usage pattern is new from v0.9. ## Earlier save_event would only accept some ical data) my_event = my_new_calendar.save_event( dtstart=datetime(2020, 5, 17, 8), dtend=datetime(2020, 5, 18, 1), summary="Do the needful", rrule={"FREQ": "YEARLY"}, ) ## Let's search for the newly added event. ## (this may fail if the server doesn't support expand) print("Here is some icalendar data:") try: events_fetched = my_new_calendar.date_search( start=datetime(2021, 5, 16), end=datetime(2024, 1, 1), expand=True ) ## Note: obj.data will always return a normal string, with normal line breaks ## obj.wire_data will return a byte string with CRLN print(events_fetched[0].data) except: print("Your calendar server does apparently not support expanded search") events_fetched = my_new_calendar.date_search( start=datetime(2020, 5, 16), end=datetime(2024, 1, 1), expand=False ) print(events_fetched[0].data) event = events_fetched[0] ## To modify an event, it's best to use either the vobject or icalendar module for it. ## The caldav library has always been supporting vobject out of the box, but icalendar is more popular. ## event.instance will as of version 0.x yield a vobject instance, but this may change in future versions. ## Both event.vobject_instance and event.icalendar_instance works from 0.7. event.vobject_instance.vevent.summary.value = "Norwegian national day celebratiuns" event.icalendar_instance.subcomponents[0][ "summary" ] = event.icalendar_instance.subcomponents[0]["summary"].replace( "celebratiuns", "celebrations" ) event.save() ## Please note that the proper way to save new icalendar data ## to the calendar is calendar.save_event(ics_data), ## while the proper way to update a calendar event is ## event.save(). Doing calendar.save_event(event.data) ## may break. See https://github.com/python-caldav/caldav/issues/153 ## for details. ## It's possible to access objects such as calendars without going ## through a Principal object if one knows the calendar URL the_same_calendar = client.calendar(url=my_new_calendar.url) ## to get all events from the calendar, it's also possible to use the ## events()-method. Recurring events will not be expanded. all_events = the_same_calendar.events() ## It's also possible to use .objects. all_objects = the_same_calendar.objects() ## since we have only added events (and neither todos nor journals), those ## should be equal ... except, all_objects is an iterator and not a list. assert len(all_events) == len(list(all_objects)) ## Let's check that the summary got right assert all_events[0].vobject_instance.vevent.summary.value.startswith("Norwegian") assert all_events[0].vobject_instance.vevent.summary.value.endswith("celebrations") ## This calendar should as a minimum support VEVENTs ... most likely ## it also supports VTODOs and maybe even VJOURNALs. We can query the ## server what it can accept: acceptable_component_types = my_new_calendar.get_supported_components() assert "VEVENT" in acceptable_component_types ## Clean up - remove the new calendar my_new_calendar.delete() ## Let's try with a task list. Some servers cannot combine events and todos in the same calendar. my_new_tasklist = my_principal.make_calendar( name="Test tasklist", supported_calendar_component_set=["VTODO"] ) ## We'll add a task to the task list my_new_tasklist.add_todo( ics="RRULE:FREQ=YEARLY", summary="Deliver some data to the Tax authorities", dtstart=date(2020, 4, 1), due=date(2020, 5, 1), categories=["family", "finance"], status="NEEDS-ACTION", ) ## Fetch the tasks todos = my_new_tasklist.todos() assert len(todos) == 1 assert "FREQ=YEARLY" in todos[0].data print("Here is some more icalendar data:") print(todos[0].data) ## date_search also works on task lists, but one has to be explicit to get them todos_found = my_new_tasklist.date_search( start=datetime(2021, 1, 1), end=datetime(2024, 1, 1), compfilter="VTODO", expand=True, ) if not todos_found: print( "Apparently your calendar server does not support searching for future instances of reoccurring tasks" ) else: print("Here is even more icalendar data:") print(todos_found[0].data) ## Mark the task as completed todos[0].complete() ## This is a yearly task. Completing it for one year should probably ## spawn a new task recurrence instance for the next year. The RFC ## says nothing about it, it seems like it's up to the clients weather ## to implement such logic or not. I've implemented such logic in the ## calendar-cli project, perhaps it should be moved into the caldav ## library, but as for now ... completing the task will cause the task ## list to be emptied. todos = my_new_tasklist.todos() assert len(todos) == 0 ## It's possible to fetch historic tasks too todos = my_new_tasklist.todos(include_completed=True) assert len(todos) == 1 ## and it's possible to delete tasks completely todos[0].delete() my_new_tasklist.delete() caldav-0.11.0/examples/scheduling_examples.py000066400000000000000000000216711433711056000212450ustar00rootroot00000000000000import sys import uuid from datetime import datetime from datetime import timedelta import pytz from caldav import DAVClient from caldav import error from icalendar import Calendar from icalendar import Event ############### ### SETUP START ### rfc6638_users should be a list with three dicts containing credential details. ### if none is given, attempt to use three test users on tobixens private calendar ### try: from tests.conf_private import rfc6638_users except: rfc6638_users = None ## Some inital setup. We'll need three caldav client objects, with ## corresponding principal objects and calendars. class TestUser: def __init__(self, i): if rfc6638_users and len(rfc6638_users) > i - 1: conndata = rfc6638_users[i - 1].copy() if "incompatibilities" in conndata: conndata.pop("incompatibilities") self.client = DAVClient(**conndata) else: self.client = DAVClient( username="testuser%i" % i, password="testpass%i" % i, url="http://calendar.tobixen.no/caldav.php/", ) self.principal = self.client.principal() calendar_id = "schedulingtestcalendar%i" % i calendar_name = "calendar #%i for scheduling demo" % i self.cleanup(calendar_name) self.calendar = self.principal.make_calendar( name=calendar_name, cal_id=calendar_id ) def cleanup(self, calendar_name): ## Cleanup from earlier runs try: self.calendar = self.principal.calendar(name=calendar_name) self.calendar.delete() except error.NotFoundError: pass ## Hmm ... perhaps we shouldn't delete inbox items # for inbox_item in self.principal.schedule_inbox().get_items(): # inbox_item.delete() organizer = TestUser(1) attendee1 = TestUser(2) attendee2 = TestUser(3) ### SETUP END ############### ## Verify that the calendar server(s) supports scheduling for test_user in organizer, attendee1, attendee2: if not test_user.client.check_scheduling_support(): print("Server does not support RFC6638") sys.exit(1) ## We'll be using the icalendar library to set up a mock meeting, ## at some far point in the future. caldata = Calendar() caldata.add("prodid", "-//tobixen//python-icalendar//en_DK") caldata.add("version", "2.0") uid = uuid.uuid1() event = Event() event.add("dtstamp", datetime.now()) event.add("dtstart", datetime.now() + timedelta(days=4000)) event.add("dtend", datetime.now() + timedelta(days=4000, hours=1)) event.add("uid", uid) event.add("summary", "Some test event made to test scheduling in the caldav library") caldata.add_component(event) caldata2 = Calendar() caldata2.add("prodid", "-//tobixen//python-icalendar//en_DK") caldata2.add("version", "2.0") uid = uuid.uuid1() event = Event() event.add("dtstamp", datetime.now()) event.add("dtstart", datetime.now() + timedelta(days=4000)) event.add("dtend", datetime.now() + timedelta(days=4000, hours=1)) event.add("uid", uid) event.add("summary", "Test event with participants but without invites") caldata2.add_component(event) ## that event is without any attendee information. If saved to the ## calendar, it will only be stored locally, no invitations sent. ## There are two ways to send calendar invites: ## * Add Attendee-lines and an Organizer-line to the event data, and ## then use calendar.save_event(caldata) ... see RFC6638, appendix B.1 ## for an example. ## * Use convenience-method calendar.save_with_invites(caldata, attendees). ## It will fetch organizer from the principal object. Method should ## accept different kind of attendees: strings, VCalAddress, (cn, ## email)-tuple and principal object. ## Lets make a list of attendees attendees = [] ## The organizer will invite himself. We'll pass a vCalAddress (from ## the icalendar library). attendees.append(organizer.principal.get_vcal_address()) ## Let's make it easy and add the other attendees by the Principal objects. ## note that we've used login credentials to get the principal ## objects below. One would normally need to know the principal ## URLs to create principal objects of other users, or perhaps use ## the principal-collection-set prop to get a list. attendees.append(attendee1.principal) attendees.append(attendee2.principal) ## An attendee can also be added by email address attendees.append("some-random-guy@example.com") ## Or by a (common_name, email) tuple attendees.append(("Some Other Random Guy", "some-other-random-guy@example.com")) print("Sending a calendar invite") organizer.calendar.save_with_invites(caldata, attendees=attendees) print( "Storing another calendar event with the same participants, but without sending out emails" ) organizer.calendar.save_with_invites( caldata2, attendees=attendees, schedule_agent="NONE" ) ## There are some attendee parameters that may be set (TODO: add ## example code), the convenience method above will use sensible ## defaults. ## The invite has now been shipped. The attendees should now respond to it. print("looking into the inbox of attendee1") all_cnt = 0 invite_req_cnt = 0 for inbox_item in attendee1.principal.schedule_inbox().get_items(): all_cnt += 1 ## an inbox_item is an ordinary CalendarResourceObject/Event/Todo etc. ## is_invite_request will be implemented on the base class and will yield True ## for invite messages. print("Inbox item found for attendee1. Here is the ical:") print(inbox_item.data) if inbox_item.is_invite_request(): print("Inbox item is an invite request") invite_req_cnt += 1 ## TODO: assert(invite_req_cnt == 1) after loop ## Ref RFC6638, example B.3 ... to respond to an invite, it's ## needed to edit the ical data, find the correct ## "attendee"-field, change the attendee "partstat", put the ## ical object back to the server. In addition one has to ## look out for race conflicts and retry the whole operation ## in case of race conflicts. Editing ical data is a bit ## outside the scope of the CalDAV client library, but ... the ## library clearly needs convenience methods to deal with this. ## Invite objects will have methods accept_invite(), ## decline_invite(), ## tentatively_accept_invite(). .delete() is also an option ## (ref RFC6638, example B.2) inbox_item.accept_invite() inbox_item.delete() ## attendee2 has other long-term plans and can't join the event for inbox_item in attendee2.principal.schedule_inbox().get_items(): print("found an inbox item for attendee 2, here is the ical:") print(inbox_item.data) if inbox_item.is_invite_request(): print("declining invite") inbox_item.decline_invite() inbox_item.delete() ## Oganizer will have an update on the participant status in the ## inbox (or perhaps two updates?) If I've understood the standard ## correctly, testuser0 should not get an invite and should not have ## to respond to it, but just in case we'll accept it. As far as I've ## understood, deleting the ical objects in the inbox should be ## harmless, it should still exist on the organizers calendar. ## (Example B.4 in RFC6638) print("looking into organizers inbox") for inbox_item in organizer.principal.schedule_inbox().get_items(): print("Inbox item found, here is the ical:") print(inbox_item.data) if inbox_item.is_invite_request(): print("It's an invite request, let's accept it") inbox_item.accept_invite() elif inbox_item.is_invite_reply(): print("It's an invite reply, now that we've read it, we can delete it") inbox_item.delete() ## RFC6638/RFC5546 allows an organizer to check the freebusy status of ## multiple principals identified by email address. It's covered in ## section 4.3.2. in RFC5546 and chapter 5 / example B.5 in RFC6638. ## Most of the logic is on the icalendar format (covered in RFC5546), ## and is a bit outside the scope of the caldav client library. ## However, I will probably make a convenience method for doing the ## query, and leaving the parsing of the returned icalendar data to ## the user of the library: import pdb pdb.set_trace() some_data_returned = organizer.principal.freebusy_request( dtstart=datetime.now().astimezone(pytz.utc) + timedelta(days=399), dtend=datetime.now().astimezone(pytz.utc) + timedelta(days=399, hours=1), attendees=[attendee1.principal, attendee2.principal], ) ## Examples in RFC6638 goes on to describing how to accept and decline ## particular instances of a recurring events, and RFC5546 has a lot ## of extra information, like ways for a participant to signal back ## new suggestions for the meeting time, delegations, cancelling of ## events and whatnot. It is possible to use the library for such ## things by saving appropriate icalendar data to the outbox and ## reading things from the inbox, but as for now there aren't any ## planned convenience methods for covering such things. caldav-0.11.0/examples/sync_examples.py000066400000000000000000000043541433711056000200730ustar00rootroot00000000000000## Some example pseudo code ("pseudo" meaning I haven't actually ## verified that the following code works - but there exists some ## similar code in the tests/test_caldav.py file. Raise a github ## issue or reach out by email or write a pull request or send a patch ## if there are mistakes in this code) ... ## USE CASE #1: we'll have a local copy of all calendar contents in a ## running python process, and later we'd like to synchronize the ## local contents. (In case of a reboot, all contents will be ## downloaded again). my_events = my_calendar.objects(load_objects=True) # (... some time later ...) my_events.sync() for event in my_events: print(event.icalendar.subcomponents[0]["SUMMARY"]) ## USE CASE #2, approach #1: We want to load all objects from the ## remote caldav server and insert them into a database. Later we ## need to do one-way syncing from the remote caldav server into the ## database. my_events = my_calendar.objects(load_objects=True) for event in my_events: save_event_to_database(event) save_sync_token_to_database(my_events.sync_token) # (... some time later ...) sync_token = load_sync_token_from_database() my_updated_events = my_calendar.objects_by_sync_token(sync_token, load_objects=True) for event in my_updated_events: if event.data is None: delete_event_from_database(event) else: update_event_in_database(event) ## USE CASE #2, approach #2, using my_events.sync(). Ref ## https://github.com/python-caldav/caldav/issues/122 this may be ## significantly faster if the caldav server tends to discard sync ## tokens or if the remote caldav server supports etags but not sync ## tokens. my_events = my_calendar.objects(load_objects=True) for event in my_events: save_event_to_database(event) save_sync_token_to_database(my_events.sync_token) # (... some time later ...) updated, deleted = my_events.sync() for event in updated: update_event_in_database(event) for event in deleted: delete_event_in_database(event) ## ... but the approach above gets a bit tricky when the server is ## rebooted/restarted. It may be possible to save the etags in the ## database, eventually. Feel free to raise a github issue or contact ## me privately if you need more support. ## Tobias Brox, 2020-12-28 caldav-0.11.0/setup.cfg000066400000000000000000000006651433711056000146530ustar00rootroot00000000000000[tox:tox] envlist = py37,py38,py39,py310,docs,style [testenv] deps = --editable .[test] commands = pytest {posargs:--cov} [testenv:docs] deps = sphinx commands = python setup.py build_sphinx [testenv:style] deps = pre-commit skip_install = true commands = pre-commit run --all-files --show-diff-on-failure [build_sphinx] source-dir = docs/source build-dir = docs/build all_files = 1 [upload_sphinx] upload-dir = docs/build/html caldav-0.11.0/setup.py000077500000000000000000000051131433711056000145400ustar00rootroot00000000000000#!/usr/bin/python # -*- encoding: utf-8 -*- import sys from setuptools import find_packages from setuptools import setup ## ATTENTION! when doing releases, the default debugmode in lib/error.py should be set to PRODUCTION. ## (TODO: any nicer ways than doing this manually? Make a "releases" branch, maybe?) version = "0.11.0" if __name__ == "__main__": ## For python 2.7 and 3.5 we depend on pytz and tzlocal. For 3.6 and up, batteries are included. Same with mock. (But unfortunately the icalendar library only support pytz timezones, so we'll keep pytz around for a bit longer). try: import datetime from datetime import timezone datetime.datetime.now().astimezone(timezone.utc) extra_packages = [] ## line below can be removed when https://github.com/collective/icalendar/issues/333 is fixed extra_packages = ["pytz", "tzlocal"] except: extra_packages = ["pytz", "tzlocal"] try: from unittest.mock import MagicMock extra_test_packages = [] except: extra_test_packages = ["mock"] test_packages = [ "pytest", "pytest-coverage", "icalendar", "coverage", "tzlocal", "pytz", "xandikos", "radicale", ] setup( name="caldav", version=version, py_modules=[ "caldav", ], description="CalDAV (RFC4791) client library", long_description=open("README.md").read(), classifiers=[ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: GNU General " "Public License (GPL)", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Topic :: Office/Business :: Scheduling", "Topic :: Software Development :: Libraries " ":: Python Modules", ], keywords="", author="Cyril Robert", author_email="cyril@hippie.io", url="https://github.com/python-caldav/caldav", license="GPL", packages=find_packages(exclude=["tests"]), include_package_data=True, zip_safe=False, install_requires=[ "vobject", "lxml", "requests", "six", "icalendar", "recurring-ical-events>=1.1.0b", ] + extra_packages, tests_require=test_packages + extra_test_packages, extras_require={ "test": test_packages, }, ) caldav-0.11.0/tests/000077500000000000000000000000001433711056000141655ustar00rootroot00000000000000caldav-0.11.0/tests/__init__.py000066400000000000000000000000001433711056000162640ustar00rootroot00000000000000caldav-0.11.0/tests/_test_absolute.py000066400000000000000000000024121433711056000175520ustar00rootroot00000000000000# encoding: utf-8 import datetime import caldav class TestRadicale(object): SUMMARIES = set( ( "Godspeed You! Black Emperor at " "Cirque Royal / Koninklijk Circus", "Standard - GBA", ) ) DTSTART = set( (datetime.datetime(2011, 3, 4, 20, 0), datetime.datetime(2011, 1, 15, 20, 0)) ) def setup(self): URL = "http://localhost:8080/nicoe/perso/" self.client = caldav.DAVClient(URL) self.calendar = caldav.objects.Calendar(self.client, URL) def test_eventslist(self): events = self.calendar.events() assert len(events) == 2 summaries, dtstart = set(), set() for event in events: event.load() vobj = event.instance summaries.add(vobj.vevent.summary.value) dtstart.add(vobj.vevent.dtstart.value) assert summaries == self.SUMMARIES assert dtstart == self.DTSTART class TestTryton(object): def setup(self): URL = "http://admin:admin@localhost:9080/caldav/Calendars/Test" self.client = caldav.DAVClient(URL) self.calendar = caldav.objects.Calendar(self.client, URL) def test_eventslist(self): events = self.calendar.events() assert len(events) == 1 caldav-0.11.0/tests/compatibility_issues.py000066400000000000000000000305201433711056000210030ustar00rootroot00000000000000# fmt: off ## The lists below are specifying what tests should be skipped or ## modified to accept non-conforming resultsets from the different ## calendar servers. In addition there are some hacks in the library ## code itself to work around some known compatibility issues, like ## the caldav.lib.vcal.fix function. ## Here is a list of all observed (in)compatibility issues the test framework needs to know about ## TODO: ## * references to the relevant parts of the RFC would be nice. ## * Research should be done to triple-check that the issue is on the server side, and not on the client side ## * Some of the things below should be possible to probe the server for. ## * Perhaps some more readable format should be considered (yaml?). ## * Consider how to get this into the documentation incompatibility_description = { 'no_expand': """Server may throw errors when asked to do a expanded date search (this is ignored by the tests now, as we're doing client-side expansion)""", 'no_recurring': """Server is having issues with recurring events and/or todos. """ """date searches covering recurrances may yield no results, """ """and events/todos may not be expanded with recurrances""", 'no_recurring_expandation': """Server will not expand recurring events (this is ignored by the tests now, as we're doing client-side expansion)""", 'no_recurring_todo': """Recurring events are supported, but not recurring todos""", 'no_recurring_todo_expand': """Recurring todos aren't expanded (this is ignored by the tests now, as we're doing client-side expansion)""", 'no_scheduling': """RFC6833 is not supported""", 'no_default_calendar': """The given user starts without an assigned default calendar """ """(or without pre-defined calendars at all)""", 'non_existing_calendar_found': """Server will not yield a 404 when accessing a random calendar URL """ """(perhaps the calendar will be automatically created on access)""", 'no_freebusy_rfc4791': """Server does not support a freebusy-request as per RFC4791""", 'no_freebusy_rfc6638': """Server does not support a freebusy-request as per RFC6638""", 'calendar_order': """Server supports (nonstandard) calendar ordering property""", 'calendar_color': """Server supports (nonstandard) calendar color property""", 'no_journal': """Server does not support journal entries""", 'no_displayname': """The display name of a calendar cannot be set/changed """ """(in zimbra, display name is given from the URL)""", 'duplicates_not_allowed': """Duplication of an event in the same calendar not allowed """ """(even with different uid)""", 'duplicate_in_other_calendar_with_same_uid_is_lost': """Fetch an event from one calendar, save it to another ... """ """and the duplicate will be ignored""", 'duplicate_in_other_calendar_with_same_uid_breaks': """Fetch an event from one calendar, save it to another ... """ """and get some error from the server""", 'event_by_url_is_broken': """A GET towards a valid calendar object resource URL will yield 404 (wtf?)""", 'no_sync_token': """RFC6578 is not supported, things will break if we try to do a sync-token report""", 'time_based_sync_tokens': """The sync token is typically a time stamp, and we need to sleep a """ """second in the test code to get things right""", 'fragile_sync_tokens': """Every now and then (or perhaps always), more content than expected """ """will be returned on a simple sync request. Possibly a race condition """ """if the token is timstamp-based?""", 'sync_breaks_on_delete': """I have observed a calendar server (sabre-based) that returned """ """418 I'm a teapot """ """when requesting updates on a calendar after some calendar resource """ """object was deleted""", 'propfind_allprop_failure': """The propfind test fails ... """ """it asserts DAV:allprop response contains the text 'resourcetype', """ """possibly this assert is wrong""", 'no_todo': """Support for VTODO (tasks) apparently missing""", 'no_todo_datesearch': """Date search on todo items fails""", 'vtodo_datesearch_nodtstart_task_is_skipped': """date searches for todo-items will not find tasks without a dtstart""", 'vtodo_datesearch_nodtstart_task_is_skipped_in_closed_date_range': """only open-ended date searches for todo-items will find tasks without a dtstart""", 'vtodo_datesearch_notime_task_is_skipped': """date searches for todo-items will (only) find tasks that has either """ """a dtstart or due set""", 'vtodo_no_due_infinite_duration': """date search will find todo-items without due if dtstart is """ """before the date search interval. I didn't find anything explicit """ """in The RFC on this (), but an event should be considered to have 0 """ """duration if no dtend is set, and most server implementations seems to """ """treat VTODOs the same""", 'no_todo_on_standard_calendar': """Tasklists can be created, but a normal calendar does not support tasks""", 'unique_calendar_ids': """For every test, generate a new and unique calendar id""", 'sticky_events': """Events should be deleted before the calendar is deleted, """ """and/or deleting a calendar may not have immediate effect""", 'object_by_uid_is_broken': """calendar.object_by_uid(uid) does not work""", 'no_mkcalendar': """mkcalendar is not supported""", 'no_overwrite': """events cannot be edited""", 'dav_not_supported': """when asked, the server may claim it doesn't support the DAV protocol. Observed by one baikal server, should be investigated more (TODO) and robur""", 'category_search_yields_nothing': """When querying for a text match report over fields like the category field, server returns nothing""", 'text_search_is_case_insensitive': """Probably not supporting the collation used by the caldav library""", 'text_search_is_exact_match_only': """Searching for 'CONF' i.e. in the class field will not yield CONFIDENTIAL. Which generally makes sense, but the RFC specifies substring match""", 'text_search_is_exact_match_sometimes': """Some servers are doing an exact match on summary field but substring match on category""", 'combined_search_not_working': """When querying for a text match and a date range in the same report, weird things happen""", 'text_search_not_working': """Text search is generally broken""", 'radicale_breaks_on_category_search': """See https://github.com/Kozea/Radicale/issues/1125""", 'fastmail_buggy_noexpand_date_search': """The 'blissful anniversary' recurrent example event is returned when asked for a no-expand date search for some timestamps covering a completely different date""", 'non_existing_raises_other': """Robur raises AuthorizationError when trying to access a non-existing resource (while 404 is expected). Probably so one shouldn't probe a public name space?""", 'no_supported_components_support': """The supported components prop query does not work""", 'rrule_takes_no_count': """Fastmail consistently yields a "502 bad gateway" when presented with a rrule containing COUNT""", 'no-current-user-principal': """when querying for the current user principal property, server doesn't report anything useful""", } xandikos = [ ## https://github.com/jelmer/xandikos/issues/8 "no_expand", "no_recurring", 'text_search_is_exact_match_only', ## This one is fixed in master branch 'category_search_yields_nothing', ## https://github.com/jelmer/xandikos/pull/194 ## scheduling is not supported "no_scheduling", ] radicale = [ ## calendar listings and calendar creation works a bit ## "weird" on radicale "no_default_calendar", "non_existing_calendar_found", ## freebusy is not supported yet, but on the long-term road map "no_freebusy_rfc4791", ## TODO: raise an issue on this one "radicale_breaks_on_category_search", ## Expanding recurrent events is not yet supported ## ref https://github.com/Kozea/Radicale/issues/662 "no_recurring_expandation", 'no_scheduling', 'text_search_is_case_insensitive', 'text_search_is_exact_match_only', 'combined_search_not_working', ## extra features not specified in RFC5545 "calendar_order", "calendar_color" ] ## ZIMBRA IS THE MOST SILLY, AND THERE ARE REGRESSIONS FOR EVERY RELEASE! ## AAARGH! zimbra = [ ## no idea why this breaks "non_existing_calendar_found", ## apparently, zimbra has no journal support 'no_journal', ## setting display name in zimbra does not work (display name, ## calendar-ID and URL is the same, the display name cannot be ## changed, it can only be given if no calendar-ID is given. In ## earlier versions of Zimbra display-name could be changed, but ## then the calendar would not be available on the old URL ## anymore) 'no_displayname', 'duplicate_in_other_calendar_with_same_uid_is_lost', 'event_by_url_is_broken', 'no_todo_on_standard_calendar', 'no_sync_token', 'vtodo_datesearch_notime_task_is_skipped', 'category_search_yields_nothing', 'text_search_is_exact_match_only', ## extra features not specified in RFC5545 "calendar_order", "calendar_color" ## TODO: there is more, it should be organized and moved here. ## Search for 'zimbra' in the code repository! ] bedework = [ ## quite a lot of things were missing in Bedework last I checked - ## but that's quite a while ago! 'no_journal', 'no_todo', 'propfind_allprop_failure', 'no_recurring', ## taking an event, changing the uid, and saving in the same calendar gives a 403. ## editing the content slightly and it works. Weird ... 'duplicates_not_allowed', 'duplicate_in_other_calendar_with_same_uid_is_lost' ] baikal = [ ## date search on todos does not seem to work ## (TODO: do some research on this) 'sync_breaks_on_delete', 'no_recurring_todo', 'no_recurring_todo_expand', 'non_existing_calendar_found', 'combined_search_not_working', 'text_search_is_exact_match_sometimes', ## extra features not specified in RFC5545 "calendar_order", "calendar_color" ] ## See comments on https://github.com/python-caldav/caldav/issues/3 icloud = [ 'unique_calendar_ids', 'duplicate_in_other_calendar_with_same_uid_breaks', 'sticky_events', 'no_journal', ## it threw a 500 internal server error! 'no_todo', "no_freebusy_rfc4791", 'no_recurring', 'propfind_allprop_failure', 'object_by_uid_is_broken' ] davical = [ #'nofreebusy', ## for old versions 'fragile_sync_tokens', 'no_journal', ## it threw a 500 internal server error! 'vtodo_datesearch_nodtstart_task_is_skipped_in_closed_date_range', ] google = [ 'no_mkcalendar', 'no_overwrite', 'no_todo', 'no_recurring_expandation' ] sogo = [ 'no_journal', 'no_freebusy_rfc4791', ## https://www.sogo.nu/bugs/view.php?id=5282 "time_based_sync_tokens", ## Left a note on https://www.sogo.nu/bugs/view.php?id=5163 "no_expand", ## https://www.sogo.nu/bugs/view.php?id=3065 ] nextcloud = [ 'sync_breaks_on_delete', 'no_recurring_todo', 'no_recurring_todo_expand', 'combined_search_not_working', 'text_search_is_exact_match_sometimes', ] fastmail = [ 'duplicates_not_allowed', 'duplicate_in_other_calendar_with_same_uid_breaks', 'no_todo', 'sticky_events', 'fastmail_buggy_noexpand_date_search', 'combined_search_not_working', 'text_search_is_exact_match_sometimes', 'rrule_takes_no_count' ] synology = [ "fragile_sync_tokens", "vtodo_datesearch_notime_task_is_skipped", "no_recurring_todo", ] robur = [ 'non_existing_raises_other', ## AuthorizationError instead of NotFoundError 'no_scheduling', 'no_sync_token', 'no_supported_components_support', 'no_journal', 'no_freebusy_rfc4791', 'no_todo_datesearch', ## returns nothing 'text_search_not_working', ] # fmt: on caldav-0.11.0/tests/conf.py000066400000000000000000000113461433711056000154710ustar00rootroot00000000000000#!/usr/bin/env python # -*- encoding: utf-8 -*- ## YOU SHOULD MOST LIKELY NOT EDIT THIS FILE! ## Make a conf_private.py for personal configuration. ## Check conf_private.py.EXAMPLE import logging from caldav.davclient import DAVClient # from .compability_issues import bedework, xandikos #################################### # Import personal test server config #################################### ## TODO: there are probably more elegant ways of doing this? try: from .conf_private import only_private ## legacy compatibility test_public_test_servers = not only_private except ImportError: try: from .conf_private import test_public_test_servers except ImportError: test_public_test_servers = False try: from .conf_private import caldav_servers except ImportError: caldav_servers = [] try: from .conf_private import test_private_test_servers if not test_private_test_servers: caldav_servers = [] except ImportError: pass try: from .conf_private import xandikos_host, xandikos_port except ImportError: xandikos_host = "localhost" xandikos_port = 8993 ## random port above 8000 try: from .conf_private import test_xandikos except ImportError: try: import xandikos test_xandikos = True except: test_xandikos = False try: from .conf_private import radicale_host, radicale_port except ImportError: radicale_host = "localhost" radicale_port = 5232 ## default radicale host try: from .conf_private import test_radicale except ImportError: try: import radicale test_radicale = True except: test_radicale = False try: from .conf_private import rfc6638_users except ImportError: rfc6638_users = [] proxy = "127.0.0.1:8080" proxy_noport = "127.0.0.1" ##################### # Public test servers ##################### ## As of 2019-09, all of those are down. Will try to fix Real Soon ... possibly before 2029 even. if False: # if test_public_test_servers: ## TODO: this one is set up on emphemeral storage on OpenShift and ## then configured manually through the webui installer, it will ## most likely only work for some few days until it's down again. ## It's needed to hard-code the configuration into ## https://github.com/python-caldav/baikal caldav_servers.append( { "url": "http://baikal-caldav-servers.cloudapps.bitbit.net/html/cal.php/", "username": "baikaluser", "password": "asdf", } ) # bedework: # * todos and journals are not properly supported - # ref https://github.com/Bedework/bedework/issues/5 # * propfind fails to return resourcetype, # ref https://github.com/Bedework/bedework/issues/110 # * date search on recurrences of recurring events doesn't work # (not reported yet - TODO) caldav_servers.append( { "url": "http://bedework-caldav-servers.cloudapps.bitbit.net/ucaldav/", "username": "vbede", "password": "bedework", "incompatibilities": compatibility_issues.bedework, } ) caldav_servers.append( { "url": "http://xandikos-caldav-servers.cloudapps.bitbit.net/", "username": "user1", "password": "password1", "incompatibilities": compatibility_issues.xandikos, } ) # radicale caldav_servers.append( { "url": "http://radicale-caldav-servers.cloudapps.bitbit.net/", "username": "testuser", "password": "123", "nofreebusy": True, "nodefaultcalendar": True, "noproxy": True, } ) ################################################################### # Convenience - get a DAVClient object from the caldav_servers list ################################################################### CONNKEYS = set( ("url", "proxy", "username", "password", "ssl_verify_cert", "ssl_cert", "auth") ) def client(idx=None, **kwargs): if idx is None and not kwargs: return client(0) elif idx is not None and not kwargs and caldav_servers: return client(**caldav_servers[idx]) elif not kwargs: return None for bad_param in ( "incompatibilities", "backwards_compatibility_url", "principal_url", "enable", ): if bad_param in kwargs: kwargs.pop(bad_param) for kw in kwargs: if not kw in CONNKEYS: logging.critical( "unknown keyword %s in connection parameters. All compatibility flags should now be sent as a separate list, see conf_private.py.EXAMPLE. Ignoring." % kw ) kwargs.pop(kw) return DAVClient(**kwargs) caldav-0.11.0/tests/conf_private.py.EXAMPLE000066400000000000000000000060571433711056000203200ustar00rootroot00000000000000from tests import compatibility_issues ## PRIVATE CALDAV SERVER(S) TO RUN TESTS TOWARDS ## Make a list of your own servers/accounts that you'd like to run the ## test towards. Running the test suite towards a personal account ## should generally be safe, it should not mess up with content there ## and it should clean up after itself, but don't sue me if anything ## goes wrong ... ## Define your primary caldav server here caldav_servers = [ { ## Set enable to False if you don't want to use a server 'enable': True ## This is all that is really needed - url, username and ## password. (the URL may even include username and password) 'url': 'https://some.server.example.com', 'username': 'testuser', 'password': 'hunter2', ## skip ssl cert verification, for self-signed certificates ## (sort of moot nowadays with letsencrypt freely available) #'ssl_cert_verify': False ## incompatibilities is a list of flags that can be set for ## skipping (parts) of certain tests. See ## tests/compatibility_issues.py for premade lists #'incompatibilities': compatibility_issues.nextcloud 'incompatibilities': [], } ] ## SOGo virtual test server ## I did roughly those steps to set up a SOGo test server: ## 1) I download the ZEG - "Zero Effort Groupware" - from https://sourceforge.net/projects/sogo-zeg/ ## 2) I installed virtualbox on my laptop ## 3) "virtualbox ~/Downloads/ZEG-5.0.0.ova" (TODO: probably it's possible to launch it "headless"?) ## 4) I clicked on some buttons to get the file "imported" and started ## 5) I went to "tools" -> "preferences" -> "network" and created a NatNetwork ## 6) I think I went to ZEG -> Settings -> Network and chose "Host-only Adapter" ## 7) SOGo was then available at http://192.168.56.101/ from my laptop ## 8) I added the lines below to my conf_private.py #caldav_servers.append({ # 'url': 'http://192.168.56.101/SOGo/dav/', # 'username': 'sogo1'. # 'password': 'sogo' #}) #for i in (1, 2, 3): # sogo = caldav_servers[-1].copy() # sogo['username'] = 'sogo%i' % i # rfc6638_users.append(sogo) ## MASTER SWITCHES FOR TEST SERVER SETUP ## With those configuration switches, pre-configured test servers in conf.py ## can be turned on or off ## test_public_test_servers - Use the list of common public test ## servers from conf.py. As of 2020-10 no public test servers exists, so this option ## is currently moot :-( test_public_test_servers = False ## test_private_test_servers - test using the list configured above in this file. test_private_test_servers = True ## test_xandikos and test_radicale ... since the xandikos and radicale caldav server implementation is ## written in python and can be instantiated quite easily, those will ## be the default caldav implementation to test towards. test_xandikos = True test_radicale = True ## For usage by ../examples/scheduling_examples.py. Should typically ## be three different users on the same caldav server. #rfc6638_users = [ caldav_servers[0], caldav_servers[1], caldav_servers[2] ] caldav-0.11.0/tests/proxy.py000066400000000000000000000265331433711056000157310ustar00rootroot00000000000000#!/usr/bin/python """Tiny HTTP Proxy. This module implements GET, HEAD, POST, PUT and DELETE methods on BaseHTTPServer, and behaves as an HTTP proxy. The CONNECT method is also implemented experimentally, but has not been tested yet. Any help will be greatly appreciated. SUZUKI Hisao 2009/11/23 - Modified by Mitko Haralanov * Added very simple FTP file retrieval * Added custom logging methods * Added code to make this a standalone application """ import ftplib import getopt import logging.handlers import os import select import signal import socket import sys import threading from time import sleep from types import CodeType from types import FrameType from caldav.lib.python_utilities import to_local from caldav.lib.python_utilities import to_wire from six import PY3 if PY3: from urllib import parse from urllib.parse import urlparse, urlunparse from http.server import BaseHTTPRequestHandler, HTTPServer from socketserver import ThreadingMixIn else: from urlparse import urlparse as parse from urlparse import urlparse, urlunparse from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer from SocketServer import ThreadingMixIn __version__ = "0.3.1" DEFAULT_LOG_FILENAME = "proxy.log" class ProxyHandler(BaseHTTPRequestHandler): __base = BaseHTTPRequestHandler __base_handle = __base.handle server_version = "TinyHTTPProxy/" + __version__ rbufsize = 0 # self.rfile Be unbuffered def handle(self): (ip, port) = self.client_address self.server.logger.log(logging.INFO, "Request from '%s'", ip) if hasattr(self, "allowed_clients") and ip not in self.allowed_clients: self.raw_requestline = self.rfile.readline() if self.parse_request(): self.send_error(403) else: self.__base_handle() def _connect_to(self, netloc, soc): i = netloc.find(":") if i >= 0: host_port = netloc[:i], int(netloc[i + 1 :]) else: host_port = netloc, 80 self.server.logger.log( logging.INFO, "connect to %s:%d", host_port[0], host_port[1] ) try: soc.connect(host_port) except socket.error as arg: try: msg = arg[1] except: msg = arg self.send_error(404, msg) return 0 return 1 def do_CONNECT(self): soc = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: if self._connect_to(self.path, soc): self.log_request(200) self.wfile.write( self.protocol_version + " 200 Connection established\r\n" ) self.wfile.write("Proxy-agent: %s\r\n" % self.version_string()) self.wfile.write("\r\n") self._read_write(soc, 300) finally: soc.close() self.connection.close() def do_GET(self): (scm, netloc, path, params, query, fragment) = urlparse(self.path, "http") if scm not in ("http", "ftp") or fragment or not netloc: self.send_error(400, "bad url %s" % self.path) return soc = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: if scm == "http": if self._connect_to(netloc, soc): self.log_request() soc.send( to_wire( "%s %s %s\r\n" % ( self.command, urlunparse(("", "", path, params, query, "")), self.request_version, ) ) ) self.headers["Connection"] = "close" del self.headers["Proxy-Connection"] for key_val in list(self.headers.items()): soc.send(to_wire("%s: %s\r\n" % key_val)) soc.send(to_wire("\r\n")) self._read_write(soc) elif scm == "ftp": # fish out user and password information i = netloc.find("@") if i >= 0: login_info, netloc = netloc[:i], netloc[i + 1 :] try: user, passwd = login_info.split(":", 1) except ValueError: user, passwd = "anonymous", None else: user, passwd = "anonymous", None self.log_request() try: ftp = ftplib.FTP(netloc) ftp.login(user, passwd) if self.command == "GET": ftp.retrbinary("RETR %s" % path, self.connection.send) ftp.quit() except Exception as e: self.server.logger.log(logging.WARNING, "FTP Exception: %s", e) finally: soc.close() self.connection.close() def _read_write(self, soc, max_idling=20, local=False): iw = [self.connection, soc] local_data = "" ow = [] count = 0 while 1: count += 1 (ins, _, exs) = select.select(iw, ow, iw, 1) if exs: break if ins: for i in ins: if i is soc: out = self.connection else: out = soc data = i.recv(8192) if data: if local: local_data += data else: out.send(data) count = 0 if count == max_idling: break if local: return to_local(local_data) return None do_HEAD = do_GET do_POST = do_GET do_PUT = do_GET do_DELETE = do_GET do_PROPFIND = do_GET def log_message(self, format, *args): self.server.logger.log( logging.INFO, "%s %s", self.address_string(), format % args ) def log_error(self, format, *args): self.server.logger.log( logging.ERROR, "%s %s", self.address_string(), format % args ) class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): def __init__(self, server_address, RequestHandlerClass, logger=None): HTTPServer.__init__(self, server_address, RequestHandlerClass) self.logger = logger class NonThreadingHTTPServer(HTTPServer): def __init__(self, server_address, RequestHandlerClass, logger=None): HTTPServer.__init__(self, server_address, RequestHandlerClass) self.logger = logger def logSetup(filename, log_size, daemon): logger = logging.getLogger("TinyHTTPProxy") logger.setLevel(logging.INFO) if not filename: if not daemon: # display to the screen handler = logging.StreamHandler() else: handler = logging.handlers.RotatingFileHandler( DEFAULT_LOG_FILENAME, maxBytes=(log_size * (1 << 20)), backupCount=5 ) else: handler = logging.handlers.RotatingFileHandler( filename, maxBytes=(log_size * (1 << 20)), backupCount=5 ) fmt = logging.Formatter( "[%(asctime)-12s.%(msecs)03d] " "%(levelname)-8s {%(name)s %(threadName)s}" " %(message)s", "%Y-%m-%d %H:%M:%S", ) handler.setFormatter(fmt) logger.addHandler(handler) return logger def usage(msg=None): if msg: print(msg) print(sys.argv[0], "[-p port] [-l logfile] [-dh] [allowed_client_name ...]]") print() print(" -p - Port to bind to") print(" -l - Path to logfile. If not specified, STDOUT is used") print(" -d - Run in the background") print() def handler(signo, frame): while frame and isinstance(frame, FrameType): if frame.f_code and isinstance(frame.f_code, CodeType): if "run_event" in frame.f_code.co_varnames: frame.f_locals["run_event"].set() return frame = frame.f_back def daemonize(logger): class DevNull(object): def __init__(self): self.fd = os.open("/dev/null", os.O_WRONLY) def write(self, *args, **kwargs): return 0 def read(self, *args, **kwargs): return 0 def fileno(self): return self.fd def close(self): os.close(self.fd) class ErrorLog: def __init__(self, obj): self.obj = obj def write(self, string): self.obj.log(logging.ERROR, string) def read(self, *args, **kwargs): return 0 def close(self): pass if os.fork() != 0: # allow the child pid to instanciate the server # class sleep(1) sys.exit(0) os.setsid() fd = os.open("/dev/null", os.O_RDONLY) if fd != 0: os.dup2(fd, 0) os.close(fd) null = DevNull() log = ErrorLog(logger) sys.stdout = null sys.stderr = log sys.stdin = null fd = os.open("/dev/null", os.O_WRONLY) # if fd != 1: os.dup2(fd, 1) os.dup2(sys.stdout.fileno(), 1) if fd != 2: os.dup2(fd, 2) if fd not in (1, 2): os.close(fd) def main(): logfile = None daemon = False max_log_size = 20 port = 8080 allowed = [] run_event = threading.Event() local_hostname = socket.gethostname() try: opts, args = getopt.getopt(sys.argv[1:], "l:dhp:", []) except getopt.GetoptError as e: usage(str(e)) return 1 for opt, value in opts: if opt == "-p": port = int(value) if opt == "-l": logfile = value if opt == "-d": daemon = not daemon if opt == "-h": usage() return 0 # setup the log file logger = logSetup(logfile, max_log_size, daemon) if daemon: daemonize(logger) signal.signal(signal.SIGINT, handler) if args: allowed = [] for name in args: client = socket.gethostbyname(name) allowed.append(client) logger.log(logging.INFO, "Accept: %s (%s)" % (client, name)) ProxyHandler.allowed_clients = allowed else: logger.log(logging.INFO, "Any clients will be served...") server_address = (socket.gethostbyname(local_hostname), port) ProxyHandler.protocol = "HTTP/1.0" httpd = ThreadingHTTPServer(server_address, ProxyHandler, logger) sa = httpd.socket.getsockname() print("Servering HTTP on", sa[0], "port", sa[1]) req_count = 0 while not run_event.isSet(): try: httpd.handle_request() req_count += 1 if req_count == 1000: logger.log( logging.INFO, "Number of active threads: %s", threading.activeCount(), ) req_count = 0 except select.error as e: if e[0] == 4 and run_event.isSet(): pass else: logger.log(logging.CRITICAL, "Errno: %d - %s", e[0], e[1]) logger.log(logging.INFO, "Server shutdown") return 0 if __name__ == "__main__": sys.exit(main()) caldav-0.11.0/tests/test_caldav.py000066400000000000000000002505441433711056000170420ustar00rootroot00000000000000#!/usr/bin/env python # -*- encoding: utf-8 -*- """ Tests here communicate with third party servers and/or internal ad-hoc instances of Xandikos and Radicale, dependent on the configuration in conf_private.py. Tests that do not require communication with a working caldav server belong in test_caldav_unit.py """ import logging import random import sys import tempfile import threading import time import uuid from collections import namedtuple from datetime import date from datetime import datetime import pytest import requests import vobject from caldav.davclient import DAVClient from caldav.davclient import DAVResponse from caldav.elements import cdav from caldav.elements import dav from caldav.elements import ical from caldav.lib import error from caldav.lib import url from caldav.lib.python_utilities import to_local from caldav.lib.python_utilities import to_str from caldav.lib.url import URL from caldav.objects import Calendar from caldav.objects import CalendarSet from caldav.objects import DAVObject from caldav.objects import Event from caldav.objects import FreeBusy from caldav.objects import Principal from caldav.objects import Todo from requests.packages import urllib3 from six import PY3 from . import compatibility_issues from .conf import caldav_servers from .conf import client from .conf import proxy from .conf import proxy_noport from .conf import radicale_host from .conf import radicale_port from .conf import rfc6638_users from .conf import test_radicale from .conf import test_xandikos from .conf import xandikos_host from .conf import xandikos_port from .proxy import NonThreadingHTTPServer from .proxy import ProxyHandler if test_xandikos: from xandikos.web import XandikosBackend, XandikosApp import aiohttp import aiohttp.web import asyncio if test_radicale: import radicale.config import radicale import radicale.server import socket if PY3: from urllib.parse import urlparse else: from urlparse import urlparse log = logging.getLogger("caldav") urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) ev1 = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VEVENT UID:20010712T182145Z-123401@example.com DTSTAMP:20060712T182145Z DTSTART:20060714T170000Z DTEND:20060715T040000Z SUMMARY:Bastille Day Party END:VEVENT END:VCALENDAR """ broken_ev1 = """BEGIN:VEVENT UID:20010712T182145Z-123401@example.com DTSTAMP:20060712T182145Z DTSTART:20060714T170000Z DTEND:20060715T040000Z SUMMARY:Bastille Day Party END:VEVENT """ ev2 = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VEVENT UID:20010712T182145Z-123401@example.com DTSTAMP:20070712T182145Z DTSTART:20070714T170000Z DTEND:20070715T040000Z SUMMARY:Bastille Day Party +1year END:VEVENT END:VCALENDAR """ ev3 = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VEVENT UID:20080712T182145Z-123401@example.com DTSTAMP:20210712T182145Z DTSTART:20210714T170000Z DTEND:20210715T040000Z SUMMARY:Bastille Day Jitsi Party END:VEVENT END:VCALENDAR """ ## This list is for deleting the events/todo-items in case it isn't ## sufficient/possible to create/delete the whole test calendar. uids_used = ( "19920901T130000Z-123407@host.com", "19920901T130000Z-123408@host.com", "19970901T130000Z-123403@example.com", "19970901T130000Z-123404@host.com", "19970901T130000Z-123405@example.com", "19970901T130000Z-123405@host.com", "19970901T130000Z-123406@host.com", "20010712T182145Z-123401@example.com", "20070313T123432Z-456553@example.com", "20080712T182145Z-123401@example.com", ) ## TODO: todo7 is an item without uid. Should be taken care of somehow. # example from http://www.rfc-editor.org/rfc/rfc5545.txt evr = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VEVENT UID:19970901T130000Z-123403@example.com DTSTAMP:19970901T130000Z DTSTART;VALUE=DATE:19971102 SUMMARY:Our Blissful Anniversary TRANSP:TRANSPARENT CLASS:CONFIDENTIAL CATEGORIES:ANNIVERSARY,PERSONAL,SPECIAL OCCASION RRULE:FREQ=YEARLY END:VEVENT END:VCALENDAR""" # example from http://www.rfc-editor.org/rfc/rfc5545.txt todo = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VTODO UID:20070313T123432Z-456553@example.com DTSTAMP:20070313T123432Z DUE;VALUE=DATE:20070501 SUMMARY:Submit Quebec Income Tax Return for 2006 CLASS:CONFIDENTIAL CATEGORIES:FAMILY,FINANCE STATUS:NEEDS-ACTION END:VTODO END:VCALENDAR""" # example from RFC2445, 4.6.2 todo2 = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VTODO UID:19970901T130000Z-123404@host.com DTSTAMP:19970901T130000Z DTSTART:19970415T133000Z DUE:19970416T045959Z SUMMARY:1996 Income Tax Preparation CLASS:CONFIDENTIAL CATEGORIES:FAMILY,FINANCE PRIORITY:2 STATUS:NEEDS-ACTION END:VTODO END:VCALENDAR""" todo3 = """ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VTODO UID:19970901T130000Z-123405@host.com DTSTAMP:19970901T130000Z DTSTART:19970415T133000Z DUE:19970516T045959Z SUMMARY:1996 Income Tax Preparation CLASS:CONFIDENTIAL CATEGORIES:FAMILY,FINANCE PRIORITY:1 END:VTODO END:VCALENDAR""" todo4 = """ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VTODO UID:19970901T130000Z-123406@host.com DTSTAMP:19970901T130000Z SUMMARY:1996 Income Tax Preparation CLASS:CONFIDENTIAL CATEGORIES:FAMILY,FINANCE PRIORITY:1 STATUS:NEEDS-ACTION END:VTODO END:VCALENDAR""" todo5 = """ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VTODO UID:19920901T130000Z-123407@host.com DTSTAMP:19920901T130000Z DTSTART:19920415T133000Z DUE:19920516T045959Z SUMMARY:1992 Income Tax Preparation CLASS:CONFIDENTIAL CATEGORIES:FAMILY,FINANCE PRIORITY:1 END:VTODO END:VCALENDAR""" todo6 = """ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VTODO UID:19920901T130000Z-123408@host.com DTSTAMP:19920901T130000Z DTSTART:19920415T133000Z DUE:19920516T045959Z SUMMARY:Yearly Income Tax Preparation RRULE:FREQ=YEARLY CLASS:CONFIDENTIAL CATEGORIES:FAMILY,FINANCE PRIORITY:1 END:VTODO END:VCALENDAR""" ## a todo without uid. Should it be possible to store it at all? todo7 = """ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VTODO DTSTAMP:19980101T130000Z DTSTART:19980415T133000Z DUE:19980516T045959Z SUMMARY:Get stuck with Netfix and forget about the tax income declaration CLASS:CONFIDENTIAL CATEGORIES:FAMILY PRIORITY:1 END:VTODO END:VCALENDAR""" todo8 = """ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VTODO UID:takeoutthethrash DTSTAMP:20221013T151313Z DTSTART:20221017T065500Z DURATION:PT10M SUMMARY:Take out the thrash before the collectors come. RRULE:FREQ=WEEKLY;BYDAY=MO;BYHOUR=6;BYMINUTE=55;COUNT=3 CATEGORIES:CHORE PRIORITY:3 END:VTODO END:VCALENDAR""" # example from http://www.kanzaki.com/docs/ical/vjournal.html journal = """ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VJOURNAL UID:19970901T130000Z-123405@example.com DTSTAMP:19970901T130000Z DTSTART;VALUE=DATE:19970317 SUMMARY:Staff meeting minutes DESCRIPTION:1. Staff meeting: Participants include Joe\\, Lisa and Bob. Aurora project plans were reviewed. There is currently no budget reserves for this project. Lisa will escalate to management. Next meeting on Tuesday.\n END:VJOURNAL END:VCALENDAR """ ## From RFC4438 examples, with some modifications sched_template = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VEVENT UID:%s SEQUENCE:0 DTSTAMP:20210206T%sZ DTSTART:203206%02iT%sZ DURATION:PT1H TRANSP:OPAQUE SUMMARY:Lunch or something END:VEVENT END:VCALENDAR """ sched = sched_template % ( str(uuid.uuid4()), "%2i%2i%2i" % (random.randint(0, 23), random.randint(0, 59), random.randint(0, 59)), random.randint(1, 28), "%2i%2i%2i" % (random.randint(0, 23), random.randint(0, 59), random.randint(0, 59)), ) @pytest.mark.skipif( not rfc6638_users, reason="need rfc6638_users to be set in order to run this test" ) @pytest.mark.skipif( len(rfc6638_users) < 3, reason="need at least three users in rfc6638_users to be set in order to run this test", ) class TestScheduling(object): """Testing support of RFC6638. TODO: work in progress. Stalled a bit due to lack of proper testing accounts. I haven't managed to get this test to pass at any systems yet, but I believe the problem is not on the library side. * icloud: cannot really test much with only one test account available. I did some testing forth and back with emails sent to an account on another service through the scheduling_examples.py, and it seems like I was able both to accept an invite from an external account (and the external account got notified about it) and to receive notification that the external party having accepted the calendar invite. FreeBusy doesn't work. I don't have capacity following up more right now. * DAViCal: I have only an old version to test with at the moment, should look into that. I did manage to send and receive a calendar invite, but apparently I did not manage to accept the calendar invite. It should be looked more into. FreeBusy doesn't work in the old version, probably it works in a newer version. * SOGo: Sending a calendar invite, but receiving nothing in the CalDAV inbox. FreeBusy works somehow, but returns pure iCalendar data and not XML, I believe that's not according to RFC6638. """ def _getCalendar(self, i): calendar_id = "schedulingnosetestcalendar%i" % i calendar_name = "caldav scheduling test %i" % i try: self.principals[i].calendar(name=calendar_name).delete() except error.NotFoundError: pass return self.principals[i].make_calendar(name=calendar_name, cal_id=calendar_id) def setup_method(self): self.clients = [] self.principals = [] for foo in rfc6638_users: c = client(**foo) self.clients.append(c) self.principals.append(c.principal()) def teardown_method(self): for i in range(0, len(self.principals)): calendar_name = "caldav scheduling test %i" % i try: self.principals[i].calendar(name=calendar_name).delete() except error.NotFoundError: pass ## TODO # def testFreeBusy(self): # pass def testInviteAndRespond(self): ## Look through inboxes of principals[0] and principals[1] so we can sort ## out existing stuff from new stuff if len(self.principals) < 2: pytest.skip("need 2 principals to do the invite and respond test") inbox_items = set( [x.url for x in self.principals[0].schedule_inbox().get_items()] ) inbox_items.update( set([x.url for x in self.principals[1].schedule_inbox().get_items()]) ) ## self.principal[0] is the organizer, and invites self.principal[1] organizers_calendar = self._getCalendar(0) attendee_calendar = self._getCalendar(1) organizers_calendar.save_with_invites( sched, [self.principals[0], self.principals[1].get_vcal_address()] ) assert len(organizers_calendar.events()) == 1 ## no new inbox items expected for principals[0] for item in self.principals[0].schedule_inbox().get_items(): assert item.url in inbox_items ## principals[1] should have one new inbox item new_inbox_items = [] for item in self.principals[1].schedule_inbox().get_items(): if not item.url in inbox_items: new_inbox_items.append(item) assert len(new_inbox_items) == 1 ## ... and the new inbox item should be an invite request assert new_inbox_items[0].is_invite_request() ## Approving the invite new_inbox_items[0].accept_invite(calendar=attendee_calendar) ## (now, this item should probably appear on a calendar somewhere ... ## TODO: make asserts on that) ## TODO: what happens if we delete that invite request now? ## principals[0] should now have a notification in the inbox that the ## calendar invite was accepted new_inbox_items = [] for item in self.principals[0].schedule_inbox().get_items(): if not item.url in inbox_items: new_inbox_items.append(item) assert len(new_inbox_items) == 1 assert new_inbox_items[0].is_invite_reply() new_inbox_items[0].delete() ## TODO. Invite two principals, let both of them load the ## invitation, and then let them respond in order. Lacks both ## tests and the implementation also apparently doesn't work as ## for now (perhaps I misunderstood the RFC). # def testAcceptedInviteRaceCondition(self): # pass ## TODO: more testing ... what happens if deleting things from the ## inbox/outbox? class RepeatedFunctionalTestsBaseClass(object): """This is a class with functional tests (tests that goes through basic functionality and actively communicates with third parties) that we want to repeat for all configured caldav_servers. (what a truely ugly name for this class - any better ideas?) NOTE: this tests relies heavily on the assumption that we can create calendars on the remote caldav server, but the RFC says ... Support for MKCALENDAR on the server is only RECOMMENDED and not REQUIRED because some calendar stores only support one calendar per user (or principal), and those are typically pre-created for each account. We've had some problems with iCloud and Radicale earlier. Google still does not support mkcalendar. """ _default_calendar = None def check_compatibility_flag(self, flag): ## yield an assertion error if checking for the wrong thig assert flag in compatibility_issues.incompatibility_description return flag in self.incompatibilities def skip_on_compatibility_flag(self, flag): if self.check_compatibility_flag(flag): msg = compatibility_issues.incompatibility_description[flag] pytest.skip("Test skipped due to server incompatibility issue: " + msg) def setup_method(self): logging.debug("############## test setup") self.incompatibilities = set() for flag in self.server_params.get("incompatibilities", []): assert flag in compatibility_issues.incompatibility_description self.incompatibilities.add(flag) if self.check_compatibility_flag("unique_calendar_ids"): self.testcal_id = "testcalendar-" + str(uuid.uuid4()) self.testcal_id2 = "testcalendar-" + str(uuid.uuid4()) else: self.testcal_id = "pythoncaldav-test" self.testcal_id2 = "pythoncaldav-test2" self.caldav = client(**self.server_params) if False and self.check_compatibility_flag("no-current-user-principal"): self.principal = Principal( client=self.caldav, url=self.server_params["principal_url"] ) else: self.principal = self.caldav.principal() logging.debug( "## going to tear down old test calendars, " "in case teardown_method wasn't properly executed " "last time tests were run" ) self._teardown_method() if self.check_compatibility_flag("object_by_uid_is_broken"): import caldav.objects caldav.objects.NotImplementedError = SkipTest logging.debug("##############################") logging.debug("############## test setup done") logging.debug("##############################") def teardown_method(self): logging.debug("############################") logging.debug("############## test teardown_method") logging.debug("############################") self._teardown_method() logging.debug("############## test teardown_method done") def _teardown_method(self): if self.check_compatibility_flag("no_mkcalendar"): for uid in uids_used: try: obj = self._fixCalendar().object_by_uid(uid) obj.delete() except error.NotFoundError: pass except: logging.error( "Something went kaboom while deleting event", exc_info=True ) return for name in ("Yep", "Yapp", "Yølp", self.testcal_id, self.testcal_id2): try: cal = self.principal.calendar(name=name) except: cal = self.principal.calendar(cal_id=name) try: if self.check_compatibility_flag("sticky_events"): try: for goo in cal.objects(): goo.delete() except: pass cal.delete() except: pass def _fixCalendar(self, **kwargs): """ Should ideally return a new calendar, if that's not possible it should see if there exists a test calendar, if that's not possible, give up and return the primary calendar. """ if self.check_compatibility_flag("no_mkcalendar"): if not self._default_calendar: calendars = self.principal.calendars() for c in calendars: if ( "pythoncaldav-test" in c.get_properties( [ dav.DisplayName(), ] ).values() ): self._default_calendar = c return c self._default_calendar = calendars[0] return self._default_calendar else: return self.principal.make_calendar( name="Yep", cal_id=self.testcal_id, **kwargs ) def testSupport(self): """ Test the check_*_support methods """ self.skip_on_compatibility_flag("dav_not_supported") assert self.caldav.check_dav_support() assert self.caldav.check_cdav_support() if self.check_compatibility_flag("no_scheduling"): assert not self.caldav.check_scheduling_support() else: assert self.caldav.check_scheduling_support() def testSchedulingInfo(self): self.skip_on_compatibility_flag("no_scheduling") inbox = self.principal.schedule_inbox() outbox = self.principal.schedule_outbox() calendar_user_address_set = self.principal.calendar_user_address_set() me_a_participant = self.principal.get_vcal_address() def testPropfind(self): """ Test of the propfind methods. (This is sort of redundant, since this is implicitly run by the setup) """ # ResourceType MUST be defined, and SHOULD be returned on a propfind # for "allprop" if I have the permission to see it. # So, no ResourceType returned seems like a bug in bedework self.skip_on_compatibility_flag("propfind_allprop_failure") # first a raw xml propfind to the root URL foo = self.caldav.propfind( self.principal.url, props='' '' " " "", ) assert "resourcetype" in to_local(foo.raw) # next, the internal _query_properties, returning an xml tree ... foo2 = self.principal._query_properties( [ dav.Status(), ] ) assert "resourcetype" in to_local(foo.raw) # TODO: more advanced asserts def testGetCalendarHomeSet(self): chs = self.principal.get_properties([cdav.CalendarHomeSet()]) assert "{urn:ietf:params:xml:ns:caldav}calendar-home-set" in chs def testGetDefaultCalendar(self): self.skip_on_compatibility_flag("no_default_calendar") assert len(self.principal.calendars()) != 0 def testGetCalendar(self): # Create calendar c = self._fixCalendar() assert c.url is not None assert len(self.principal.calendars()) != 0 ## Not sure if those asserts make much sense, the main point here is to exercise ## the __str__ and __repr__ methods on the Calendar object. name = c.get_property(dav.DisplayName(), use_cached=True) if not name: name = c.url assert str(name) == str(c) assert str(name) in repr(c) assert "Calendar" in repr(c) def testProxy(self): if self.caldav.url.scheme == "https": pytest.skip( "Skipping %s.testProxy as the TinyHTTPProxy " "implementation doesn't support https" ) self.skip_on_compatibility_flag("no_default_calendar") server_address = ("127.0.0.1", 8080) try: proxy_httpd = NonThreadingHTTPServer( server_address, ProxyHandler, logging.getLogger("TinyHTTPProxy") ) except: pytest.skip("Unable to set up proxy server") threadobj = threading.Thread(target=proxy_httpd.serve_forever) try: threadobj.start() assert threadobj.is_alive() conn_params = self.server_params.copy() conn_params["proxy"] = proxy c = client(**conn_params) p = c.principal() assert len(p.calendars()) != 0 finally: proxy_httpd.shutdown() # this should not be necessary, but I've observed some failures if threadobj.is_alive(): time.sleep(0.15) assert not threadobj.is_alive() threadobj = threading.Thread(target=proxy_httpd.serve_forever) try: threadobj.start() assert threadobj.is_alive() conn_params = self.server_params.copy() conn_params["proxy"] = proxy_noport c = client(**conn_params) p = c.principal() assert len(p.calendars()) != 0 assert threadobj.is_alive() finally: proxy_httpd.shutdown() # this should not be necessary if threadobj.is_alive(): time.sleep(0.05) assert not threadobj.is_alive() def _notFound(self): if self.check_compatibility_flag("non_existing_raises_other"): return error.DAVError else: return error.NotFoundError def testPrincipal(self): collections = self.principal.calendars() if "principal_url" in self.server_params: assert self.principal.url == self.server_params["principal_url"] for c in collections: assert c.__class__.__name__ == "Calendar" def testCreateDeleteCalendar(self): self.skip_on_compatibility_flag("no_mkcalendar") c = self.principal.make_calendar(name="Yep", cal_id=self.testcal_id) assert c.url is not None events = c.events() assert len(events) == 0 events = self.principal.calendar(name="Yep", cal_id=self.testcal_id).events() assert len(events) == 0 c.delete() # this breaks with zimbra and radicale if not self.check_compatibility_flag("non_existing_calendar_found"): with pytest.raises(self._notFound()): self.principal.calendar(name="Yep", cal_id=self.testcal_id).events() def testCreateEvent(self): c = self._fixCalendar() existing_events = c.events() if not self.check_compatibility_flag("no_mkcalendar"): ## we're supposed to be working towards a brand new calendar assert len(existing_events) == 0 # add event c.save_event(broken_ev1) # c.events() should give a full list of events events = c.events() assert len(events) == len(existing_events) + 1 # We should be able to access the calender through the URL c2 = self.caldav.calendar(url=c.url) events2 = c2.events() assert len(events2) == len(existing_events) + 1 assert events2[0].url == events[0].url if not self.check_compatibility_flag( "no_mkcalendar" ) and not self.check_compatibility_flag("no_displayname"): # We should be able to access the calender through the name c2 = self.principal.calendar(name="Yep") events2 = c2.events() assert len(events2) == 1 assert events2[0].url == events[0].url # add another event, it should be doable without having premade ICS ev2 = c.save_event( dtstart=datetime(2015, 10, 10, 8, 7, 6), summary="This is a test event", dtend=datetime(2016, 10, 10, 9, 8, 7), ) events = c.events() assert len(events) == len(existing_events) + 2 def testCalendarByFullURL(self): """ ref private email, passing a full URL as cal_id works in 0.5.0 but is broken in 0.8.0 """ mycal = self._fixCalendar() samecal = self.caldav.principal().calendar(cal_id=str(mycal.url)) assert mycal.url.canonical() == samecal.url.canonical() ## passing cal_id as an URL object should also work. samecal = self.caldav.principal().calendar(cal_id=mycal.url) assert mycal.url.canonical() == samecal.url.canonical() def testObjectBySyncToken(self): """ Support for sync-collection reports, ref https://github.com/python-caldav/caldav/issues/87. This test is using explicit calls to objects_by_sync_token """ self.skip_on_compatibility_flag("no_sync_token") ## Boiler plate ... make a calendar and add some content c = self._fixCalendar() objcnt = 0 ## in case we need to reuse an existing calendar ... if not self.check_compatibility_flag("no_todo"): objcnt += len(c.todos()) objcnt += len(c.events()) obj = c.save_event(ev1) objcnt += 1 if not self.check_compatibility_flag("no_recurring"): c.save_event(evr) objcnt += 1 if not self.check_compatibility_flag( "no_todo" ) and not self.check_compatibility_flag("no_todo_on_standard_calendar"): c.save_todo(todo) c.save_todo(todo2) c.save_todo(todo3) objcnt += 3 if self.check_compatibility_flag("time_based_sync_tokens"): time.sleep(1) ## objects should return all objcnt object. my_objects = c.objects() assert my_objects.sync_token != "" assert len(list(my_objects)) == objcnt ## They should not be loaded. for some_obj in my_objects: assert some_obj.data is None if self.check_compatibility_flag("time_based_sync_tokens"): time.sleep(1) ## running sync_token again with the new token should return 0 hits my_changed_objects = c.objects_by_sync_token(sync_token=my_objects.sync_token) if not self.check_compatibility_flag("fragile_sync_tokens"): assert len(list(my_changed_objects)) == 0 ## I was unable to run the rest of the tests towards Google using their legacy caldav API self.skip_on_compatibility_flag("no_overwrite") ## MODIFYING an object if self.check_compatibility_flag("time_based_sync_tokens"): time.sleep(1) obj.icalendar_instance.subcomponents[0]["SUMMARY"] = "foobar" obj.save() if self.check_compatibility_flag("time_based_sync_tokens"): time.sleep(1) ## The modified object should be returned by the server my_changed_objects = c.objects_by_sync_token( sync_token=my_changed_objects.sync_token, load_objects=True ) if self.check_compatibility_flag("fragile_sync_tokens"): assert len(list(my_changed_objects)) >= 1 else: assert len(list(my_changed_objects)) == 1 ## this time it should be loaded assert list(my_changed_objects)[0].data is not None if self.check_compatibility_flag("time_based_sync_tokens"): time.sleep(1) ## Re-running objects_by_sync_token, and no objects should be returned my_changed_objects = c.objects_by_sync_token( sync_token=my_changed_objects.sync_token ) if not self.check_compatibility_flag("fragile_sync_tokens"): assert len(list(my_changed_objects)) == 0 ## ADDING yet another object ... and it should also be reported if self.check_compatibility_flag("time_based_sync_tokens"): time.sleep(1) obj3 = c.save_event(ev3) if self.check_compatibility_flag("time_based_sync_tokens"): time.sleep(1) my_changed_objects = c.objects_by_sync_token( sync_token=my_changed_objects.sync_token ) if not self.check_compatibility_flag("fragile_sync_tokens"): assert len(list(my_changed_objects)) == 1 if self.check_compatibility_flag("time_based_sync_tokens"): time.sleep(1) ## Re-running objects_by_sync_token, and no objects should be returned my_changed_objects = c.objects_by_sync_token( sync_token=my_changed_objects.sync_token ) if not self.check_compatibility_flag("fragile_sync_tokens"): assert len(list(my_changed_objects)) == 0 if self.check_compatibility_flag("time_based_sync_tokens"): time.sleep(1) ## DELETING the object ... and it should be reported obj.delete() self.skip_on_compatibility_flag("sync_breaks_on_delete") if self.check_compatibility_flag("time_based_sync_tokens"): time.sleep(1) my_changed_objects = c.objects_by_sync_token( sync_token=my_changed_objects.sync_token, load_objects=True ) if not self.check_compatibility_flag("fragile_sync_tokens"): assert len(list(my_changed_objects)) == 1 if self.check_compatibility_flag("time_based_sync_tokens"): time.sleep(1) ## even if we have asked for the object to be loaded, data should be None as it's a deleted object assert list(my_changed_objects)[0].data is None ## Re-running objects_by_sync_token, and no objects should be returned my_changed_objects = c.objects_by_sync_token( sync_token=my_changed_objects.sync_token ) if not self.check_compatibility_flag("fragile_sync_tokens"): assert len(list(my_changed_objects)) == 0 def testSync(self): """ Support for sync-collection reports, ref https://github.com/python-caldav/caldav/issues/87. Same test pattern as testObjectBySyncToken, but exercises the .sync() method """ self.skip_on_compatibility_flag("no_sync_token") ## Boiler plate ... make a calendar and add some content c = self._fixCalendar() objcnt = 0 ## in case we need to reuse an existing calendar ... if not self.check_compatibility_flag("no_todo"): objcnt += len(c.todos()) objcnt += len(c.events()) obj = c.save_event(ev1) objcnt += 1 if not self.check_compatibility_flag("no_recurring"): c.save_event(evr) objcnt += 1 if not self.check_compatibility_flag( "no_todo" ) and not self.check_compatibility_flag("no_todo_on_standard_calendar"): c.save_todo(todo) c.save_todo(todo2) c.save_todo(todo3) objcnt += 3 if self.check_compatibility_flag("time_based_sync_tokens"): time.sleep(1) ## objects should return all objcnt object. my_objects = c.objects(load_objects=True) assert my_objects.sync_token != "" assert len(list(my_objects)) == objcnt if self.check_compatibility_flag("time_based_sync_tokens"): time.sleep(1) ## sync() should do nothing updated, deleted = my_objects.sync() if not self.check_compatibility_flag("fragile_sync_tokens"): assert len(list(updated)) == 0 assert len(list(deleted)) == 0 if self.check_compatibility_flag("time_based_sync_tokens"): time.sleep(1) ## I was unable to run the rest of the tests towards Google using their legacy caldav API self.skip_on_compatibility_flag("no_overwrite") ## MODIFYING an object obj.icalendar_instance.subcomponents[0]["SUMMARY"] = "foobar" obj.save() if self.check_compatibility_flag("time_based_sync_tokens"): time.sleep(1) updated, deleted = my_objects.sync() if not self.check_compatibility_flag("fragile_sync_tokens"): assert len(list(updated)) == 1 assert len(list(deleted)) == 0 assert "foobar" in my_objects.objects_by_url()[obj.url].data if self.check_compatibility_flag("time_based_sync_tokens"): time.sleep(1) ## ADDING yet another object ... and it should also be reported obj3 = c.save_event(ev3) if self.check_compatibility_flag("time_based_sync_tokens"): time.sleep(1) updated, deleted = my_objects.sync() if not self.check_compatibility_flag("fragile_sync_tokens"): assert len(list(updated)) == 1 assert len(list(deleted)) == 0 assert obj3.url in my_objects.objects_by_url() self.skip_on_compatibility_flag("sync_breaks_on_delete") if self.check_compatibility_flag("time_based_sync_tokens"): time.sleep(1) ## DELETING the object ... and it should be reported obj.delete() if self.check_compatibility_flag("time_based_sync_tokens"): time.sleep(1) updated, deleted = my_objects.sync() if not self.check_compatibility_flag("fragile_sync_tokens"): assert len(list(updated)) == 0 assert len(list(deleted)) == 1 assert not obj.url in my_objects.objects_by_url() if self.check_compatibility_flag("time_based_sync_tokens"): time.sleep(1) ## sync() should do nothing updated, deleted = my_objects.sync() if not self.check_compatibility_flag("fragile_sync_tokens"): assert len(list(updated)) == 0 assert len(list(deleted)) == 0 def testLoadEvent(self): self.skip_on_compatibility_flag("no_mkcalendar") c1 = self.principal.make_calendar(name="Yep", cal_id=self.testcal_id) c2 = self.principal.make_calendar(name="Yapp", cal_id=self.testcal_id2) e1_ = c1.save_event(ev1) if not self.check_compatibility_flag("event_by_url_is_broken"): e1_.load() e1 = c1.events()[0] assert e1.url == e1_.url if not self.check_compatibility_flag("event_by_url_is_broken"): e1.load() def testCopyEvent(self): self.skip_on_compatibility_flag("no_mkcalendar") ## Let's create two calendars, and populate one event on the first calendar c1 = self.principal.make_calendar(name="Yep", cal_id=self.testcal_id) c2 = self.principal.make_calendar(name="Yapp", cal_id=self.testcal_id2) e1_ = c1.save_event(ev1) e1 = c1.events()[0] if not self.check_compatibility_flag("duplicates_not_allowed"): ## Duplicate the event in the same calendar, with new uid e1_dup = e1.copy() e1_dup.save() assert len(c1.events()) == 2 if not self.check_compatibility_flag( "duplicate_in_other_calendar_with_same_uid_breaks" ): e1_in_c2 = e1.copy(new_parent=c2, keep_uid=True) e1_in_c2.save() if not self.check_compatibility_flag( "duplicate_in_other_calendar_with_same_uid_is_lost" ): assert len(c2.events()) == 1 ## what will happen with the event in c1 if we modify the event in c2, ## which shares the id with the event in c1? e1_in_c2.instance.vevent.summary.value = "asdf" e1_in_c2.save() e1.load() ## should e1.summary be 'asdf' or 'Bastille Day Party'? I do ## not know, but all implementations I've tested will treat ## the copy in the other calendar as a distinct entity, even ## if the uid is the same. assert e1.instance.vevent.summary.value == "Bastille Day Party" assert c2.events()[0].instance.vevent.uid == e1.instance.vevent.uid ## Duplicate the event in the same calendar, with same uid - ## this makes no sense, there won't be any duplication e1_dup2 = e1.copy(keep_uid=True) e1_dup2.save() if self.check_compatibility_flag("duplicates_not_allowed"): assert len(c1.events()) == 1 else: assert len(c1.events()) == 2 def testCreateCalendarAndEventFromVobject(self): c = self._fixCalendar() ## in case the calendar is reused cnt = len(c.events()) # add event from vobject data ve1 = vobject.readOne(ev1) c.save_event(ve1) cnt += 1 # c.events() should give a full list of events events = c.events() assert len(events) == cnt # This makes no sense, it's a noop. Perhaps an error # should be raised, but as for now, this is simply ignored. c.save_event(None) assert len(c.events()) == cnt def testGetSupportedComponents(self): self.skip_on_compatibility_flag("no_supported_components_support") c = self._fixCalendar() components = c.get_supported_components() assert components assert "VEVENT" in components def testSearchEvent(self): c = self._fixCalendar() c.save_event(ev1) c.save_event(ev3) c.save_event(evr) ## Search without any parameters should yield everything on calendar all_events = c.search() assert len(all_events) == 3 ## Search with comp_class set to Event should yield all events on calendar all_events = c.search(comp_class=Event) assert len(all_events) == 3 ## Search with todo flag set should yield no events no_events = c.search(todo=True) assert len(no_events) == 0 ## Date search should be possible some_events = c.search( comp_class=Event, expand=False, start=datetime(2006, 7, 13, 13, 0), end=datetime(2006, 7, 15, 13, 0), ) if not self.check_compatibility_flag("fastmail_buggy_noexpand_date_search"): assert len(some_events) == 1 ## Search for misc text fields ## UID is a special case, supported by almost all servers some_events = c.search( comp_class=Event, uid="19970901T130000Z-123403@example.com" ) if not self.check_compatibility_flag("text_search_not_working"): assert len(some_events) == 1 ## class some_events = c.search(comp_class=Event, class_="CONFIDENTIAL") if not self.check_compatibility_flag("text_search_not_working"): assert len(some_events) == 1 ## category if not self.check_compatibility_flag("radicale_breaks_on_category_search"): some_events = c.search(comp_class=Event, category="PERSONAL") if not self.check_compatibility_flag( "category_search_yields_nothing" ) and not self.check_compatibility_flag("text_search_not_working"): assert len(some_events) == 1 some_events = c.search(comp_class=Event, category="personal") if not self.check_compatibility_flag( "category_search_yields_nothing" ) and not self.check_compatibility_flag("text_search_not_working"): if self.check_compatibility_flag("text_search_is_case_insensitive"): assert len(some_events) == 1 else: assert len(some_events) == 0 ## This is not a very useful search, and it's sort of a client side bug that we allow it at all. ## It will not match if categories field is set to "PERSONAL,ANNIVERSARY,SPECIAL OCCATION" ## It may not match since the above is to be considered equivalent to the raw data entered. some_events = c.search( comp_class=Event, category="ANNIVERSARY,PERSONAL,SPECIAL OCCASION" ) if not self.check_compatibility_flag("text_search_not_working"): assert len(some_events) in (0, 1) ## TODO: This is actually a bug. We need to do client side filtering some_events = c.search(comp_class=Event, category="PERSON") if self.check_compatibility_flag("text_search_is_exact_match_only"): assert len(some_events) == 0 elif not self.check_compatibility_flag( "category_search_yields_nothing" ) and not self.check_compatibility_flag("text_search_not_working"): assert len(some_events) == 1 ## I expect logical and when combining category with a date range no_events = c.search( comp_class=Event, category="PERSONAL", start=datetime(2006, 7, 13, 13, 0), end=datetime(2006, 7, 15, 13, 0), ) if ( not self.check_compatibility_flag("category_search_yields_nothing") and not self.check_compatibility_flag("combined_search_not_working") and not self.check_compatibility_flag("text_search_not_working") ): assert len(no_events) == 0 some_events = c.search( comp_class=Event, category="PERSONAL", start=datetime(1997, 11, 1, 13, 0), end=datetime(1997, 11, 3, 13, 0), ) if ( not self.check_compatibility_flag("category_search_yields_nothing") and not self.check_compatibility_flag("combined_search_not_working") and not self.check_compatibility_flag("text_search_not_working") ): assert len(some_events) == 1 some_events = c.search(comp_class=Event, summary="Bastille Day Party") if not self.check_compatibility_flag("text_search_not_working"): if self.check_compatibility_flag("text_search_is_exact_match_sometimes"): assert len(some_events) in (1, 2) elif self.check_compatibility_flag("text_search_is_exact_match_only"): assert len(some_events) == 1 else: assert len(some_events) == 2 ## Even sorting should work out all_events = c.search(sort_keys=("summary", "dtstamp")) assert len(all_events) == 3 assert all_events[0].instance.vevent.summary.value == "Bastille Day Jitsi Party" def testSearchTodos(self): self.skip_on_compatibility_flag("no_todo") c = self._fixCalendar(supported_calendar_component_set=["VTODO"]) t1 = c.save_todo(todo) t2 = c.save_todo(todo2) t3 = c.save_todo(todo3) t4 = c.save_todo(todo4) t5 = c.save_todo(todo5) t6 = c.save_todo(todo6) ## Search without any parameters should yield everything on calendar all_todos = c.search() assert len(all_todos) == 6 ## Search with comp_class set to Event should yield all events on calendar all_todos = c.search(comp_class=Event) assert len(all_todos) == 0 ## Search with todo flag set should yield all 6 events all_todos = c.search(todo=True) assert len(all_todos) == 6 ## Search for misc text fields ## UID is a special case, supported by almost all servers some_todos = c.search(comp_class=Todo, uid="19970901T130000Z-123404@host.com") if not self.check_compatibility_flag("text_search_not_working"): assert len(some_todos) == 1 ## class ... hm, all 6 example todos are 'CONFIDENTIAL' ... some_todos = c.search(comp_class=Todo, class_="CONFIDENTIAL") if not self.check_compatibility_flag("text_search_not_working"): assert len(some_todos) == 6 ## category self.skip_on_compatibility_flag("radicale_breaks_on_category_search") ## Too much copying of the examples ... some_todos = c.search(comp_class=Todo, category="FINANCE") if not self.check_compatibility_flag( "category_search_yields_nothing" ) and not self.check_compatibility_flag("text_search_not_working"): assert len(some_todos) == 6 some_todos = c.search(comp_class=Todo, category="finance") if not self.check_compatibility_flag( "category_search_yields_nothing" ) and not self.check_compatibility_flag("text_search_not_working"): if self.check_compatibility_flag("text_search_is_case_insensitive"): assert len(some_todos) == 6 else: assert len(some_todos) == 0 ## This is not a very useful search, and it's sort of a client side bug that we allow it at all. ## It will not match if categories field is set to "PERSONAL,ANNIVERSARY,SPECIAL OCCATION" ## It may not match since the above is to be considered equivalent to the raw data entered. some_todos = c.search(comp_class=Event, category="FAMILY,FINANCE") if not self.check_compatibility_flag("text_search_not_working"): assert len(some_todos) in (0, 1) ## TODO: This is actually a bug. We need to do client side filtering some_todos = c.search(comp_class=Todo, category="MIL") if self.check_compatibility_flag("text_search_is_exact_match_sometimes"): assert len(some_todos) in (0, 6) elif self.check_compatibility_flag("text_search_is_exact_match_only"): assert len(some_todos) == 0 elif not self.check_compatibility_flag( "category_search_yields_nothing" ) and not self.check_compatibility_flag("text_search_not_working"): assert len(some_todos) == 1 ## completing an event, and it should not show up anymore t1.complete() t2.complete() t3.complete() t4.complete() some_todos = c.search(todo=True) assert len(some_todos) == 2 ## unless we specifically ask for completed tasks all_todos = c.search(todo=True, include_completed=True) assert len(all_todos) == 6 def testCreateJournalListAndJournalEntry(self): """ This test demonstrates the support for journals. * It will create a journal list * It will add some journal entries to it * It will list out all journal entries """ self.skip_on_compatibility_flag("no_journal") c = self._fixCalendar(supported_calendar_component_set=["VJOURNAL"]) j1 = c.save_journal(journal) journals = c.journals() assert len(journals) == 1 j1_ = c.journal_by_uid(j1.id) j1_.icalendar_instance journals[0].icalendar_instance assert j1_.data == journals[0].data j2 = c.save_journal( dtstart=date(2011, 11, 11), summary="A childbirth in a hospital in Kupchino", description="A quick birth, in the middle of the night", ) assert len(c.journals()) == 2 todos = c.todos() events = c.events() assert todos + events == [] def testCreateTaskListAndTodo(self): """ This test demonstrates the support for task lists. * It will create a "task list" * It will add a task to it * Verify the cal.todos() method * Verify that cal.events() method returns nothing """ # bedeworks and google calendar and some others does not support VTODO self.skip_on_compatibility_flag("no_todo") # For most servers (notable exception Zimbra), it's # possible to create a calendar and add todo-items to it. # Zimbra has separate calendars and task lists, and it's not # allowed to put TODO-tasks into the calendar. We need to # tell Zimbra that the new "calendar" is a task list. This # is done though the supported_calendar_compontent_set # property - hence the extra parameter here: logging.info("Creating calendar Yep for tasks") c = self._fixCalendar(supported_calendar_component_set=["VTODO"]) # add todo-item logging.info("Adding todo item to calendar Yep") t1 = c.save_todo(todo) assert t1.id == "20070313T123432Z-456553@example.com" # c.todos() should give a full list of todo items logging.info("Fetching the full list of todo items (should be one)") todos = c.todos() todos2 = c.todos(include_completed=True) assert len(todos) == 1 assert len(todos2) == 1 t3 = c.save_todo(summary="mop the floor", categories=["housework"], priority=4) assert len(c.todos()) == 2 # adding a todo without an UID, it should also work (library will add the missing UID) c.save_todo(todo7) assert len(c.todos()) == 3 logging.info("Fetching the events (should be none)") # c.events() should NOT return todo-items events = c.events() assert len(events) == 0 def testTodos(self): """ This test will excercise the cal.todos() method, and in particular the sort_keys attribute. * It will list out all pending tasks, sorted by due date * It will list out all pending tasks, sorted by priority """ # Not all server implementations have support for VTODO self.skip_on_compatibility_flag("no_todo") c = self._fixCalendar(supported_calendar_component_set=["VTODO"]) # add todo-item t1 = c.save_todo(todo) t2 = c.save_todo(todo2) t3 = c.save_todo(todo3) todos = c.todos() assert len(todos) == 3 def uids(lst): return [x.instance.vtodo.uid for x in lst] assert uids(todos) == uids([t2, t3, t1]) todos = c.todos(sort_keys=("priority",)) ## sort_key is considered to be a legacy parameter, ## but should work at least until 1.0 todos2 = c.todos(sort_key="priority") def pri(lst): return [ x.instance.vtodo.priority.value for x in lst if hasattr(x.instance.vtodo, "priority") ] assert pri(todos) == pri([t3, t2]) assert pri(todos2) == pri([t3, t2]) todos = c.todos( sort_keys=( "summary", "priority", ) ) assert uids(todos) == uids([t3, t2, t1]) ## str of CalendarObjectResource is slightly inconsistent compared to ## the str of Calendar objects, as the class name is included. Perhaps ## it should be removed, hence no assertions on that. ## (the statements below is mostly to exercise the __str__ and __repr__) assert str(todos[0].url) in str(todos[0]) assert str(todos[0].url) in repr(todos[0]) assert "Todo" in repr(todos[0]) def testTodoDatesearch(self): """ Let's see how the date search method works for todo events """ # bedeworks does not support VTODO self.skip_on_compatibility_flag("no_todo") self.skip_on_compatibility_flag("no_todo_datesearch") c = self._fixCalendar(supported_calendar_component_set=["VTODO"]) # add todo-item t1 = c.save_todo(todo) t2 = c.save_todo(todo2) t3 = c.save_todo(todo3) t4 = c.save_todo(todo4) t5 = c.save_todo(todo5) t6 = c.save_todo(todo6) todos = c.todos() assert len(todos) == 6 notodos = c.date_search( # default compfilter is events start=datetime(1997, 4, 14), end=datetime(2015, 5, 14), expand=False ) assert not notodos # Now, this is interesting. # t1 has due set but not dtstart set # t2 and t3 has dtstart and due set # t4 has neither dtstart nor due set. # t5 has dtstart and due set prior to the search window # t6 has dtstart and due set prior to the search window, but is yearly recurring. # What will a date search yield? noexpand = self.check_compatibility_flag("no_expand") todos1 = c.date_search( start=datetime(1997, 4, 14), end=datetime(2015, 5, 14), compfilter="VTODO", expand=not noexpand, ) todos2 = c.search( start=datetime(1997, 4, 14), end=datetime(2015, 5, 14), todo=True, expand=not noexpand, split_expanded=False, include_completed=True, ) # The RFCs are pretty clear on this. rfc5545 states: # A "VTODO" calendar component without the "DTSTART" and "DUE" (or # "DURATION") properties specifies a to-do that will be associated # with each successive calendar date, until it is completed. # and RFC4791, section 9.9 also says that events without # dtstart or due should be counted. The expanded yearly event # should be returned as one object with multiple BEGIN:VEVENT # and DTSTART lines. # Hence a compliant server should chuck out all the todos except t5. # Not all servers perform according to (my interpretation of) the RFC. foo = 5 if self.check_compatibility_flag( "no_recurring" ) or self.check_compatibility_flag("no_recurring_todo"): foo -= 1 ## t6 will not be returned if self.check_compatibility_flag( "vtodo_datesearch_nodtstart_task_is_skipped" ) or self.check_compatibility_flag( "vtodo_datesearch_nodtstart_task_is_skipped_in_closed_date_range" ): foo -= 2 ## t1 and t4 not returned elif self.check_compatibility_flag("vtodo_datesearch_notime_task_is_skipped"): foo -= 1 ## t4 not returned assert len(todos1) == foo assert len(todos2) == foo ## verify that "expand" works if ( not self.check_compatibility_flag("no_expand") and not self.check_compatibility_flag("no_recurring") and not self.check_compatibility_flag("no_recurring_todo") ): assert len([x for x in todos1 if "DTSTART:20020415T1330" in x.data]) == 1 ## exercise the default for expand (maybe -> False for open-ended search) todos1 = c.date_search(start=datetime(2025, 4, 14), compfilter="VTODO") todos2 = c.search( start=datetime(2025, 4, 14), todo=True, include_completed=True ) todos3 = c.search(start=datetime(2025, 4, 14), todo=True) assert isinstance(todos1[0], Todo) assert isinstance(todos2[0], Todo) if not self.check_compatibility_flag("combined_search_not_working"): assert isinstance(todos3[0], Todo) ## * t6 should be returned, as it's a yearly task spanning over 2025 ## * t1 should probably be returned, as it has no due date set and hence ## has an infinite duration. ## * t4 should probably be returned, as it has no dtstart nor due and ## hence is also considered to span over infinite time urls_found = [x.url for x in todos1] urls_found2 = [x.url for x in todos1] assert urls_found == urls_found2 if not ( self.check_compatibility_flag("no_recurring") or self.check_compatibility_flag("no_recurring_todo") ): urls_found.remove(t6.url) if not self.check_compatibility_flag( "vtodo_datesearch_nodtstart_task_is_skipped" ) and not self.check_compatibility_flag( "vtodo_datesearch_notime_task_is_skipped" ): urls_found.remove(t4.url) if self.check_compatibility_flag("vtodo_no_due_infinite_duration"): urls_found.remove(t1.url) ## everything should be popped from urls_found by now assert len(urls_found) == 0 assert len([x for x in todos1 if "DTSTART:20270415T1330" in x.data]) == 0 assert len([x for x in todos2 if "DTSTART:20270415T1330" in x.data]) == 0 # TODO: prod the caldav server implementators about the RFC # breakages. def testTodoCompletion(self): """ Will check that todo-items can be completed and deleted """ # not all caldav servers support VTODO self.skip_on_compatibility_flag("no_todo") c = self._fixCalendar(supported_calendar_component_set=["VTODO"]) # add todo-items t1 = c.save_todo(todo) t2 = c.save_todo(todo2) t3 = c.save_todo(todo3) # There are now three todo-items at the calendar todos = c.todos() assert len(todos) == 3 # Complete one of them t3.complete() # There are now two todo-items at the calendar todos = c.todos() assert len(todos) == 2 # The historic todo-item can still be accessed todos = c.todos(include_completed=True) assert len(todos) == 3 t3_ = c.todo_by_uid(t3.id) assert t3_.instance.vtodo.summary == t3.instance.vtodo.summary assert t3_.instance.vtodo.uid == t3.instance.vtodo.uid assert t3_.instance.vtodo.dtstart == t3.instance.vtodo.dtstart t2.delete() # ... the deleted one is gone ... if not self.check_compatibility_flag("event_by_url_is_broken"): todos = c.todos(include_completed=True) assert len(todos) == 2 # date search should not include completed events ... hum. # TODO, fixme. # todos = c.date_search( # start=datetime(1990, 4, 14), end=datetime(2015,5,14), # compfilter='VTODO', hide_completed_todos=True) # assert len(todos) == 1 def testTodoRecurringCompleteSafe(self): c = self._fixCalendar(supported_calendar_component_set=["VTODO"]) t6 = c.save_todo(todo6) if not self.check_compatibility_flag("rrule_takes_no_count"): t8 = c.save_todo(todo8) if not self.check_compatibility_flag("rrule_takes_no_count"): assert len(c.todos()) == 2 else: assert len(c.todos()) == 1 t6.complete(handle_rrule=True, rrule_mode="safe") if self.check_compatibility_flag("rrule_takes_no_count"): assert len(c.todos()) == 1 assert len(c.todos(include_completed=True)) == 2 self.skip_on_compatibility_flag("rrule_takes_no_count") assert len(c.todos()) == 2 assert len(c.todos(include_completed=True)) == 3 t8.complete(handle_rrule=True, rrule_mode="safe") assert len(c.todos()) == 2 t8.complete(handle_rrule=True, rrule_mode="safe") t8.complete(handle_rrule=True, rrule_mode="safe") assert len(c.todos()) == 1 def testTodoRecurringCompleteThisandfuture(self): c = self._fixCalendar(supported_calendar_component_set=["VTODO"]) t6 = c.save_todo(todo6) if not self.check_compatibility_flag("rrule_takes_no_count"): t8 = c.save_todo(todo8) if not self.check_compatibility_flag("rrule_takes_no_count"): assert len(c.todos()) == 2 else: assert len(c.todos()) == 1 t6.complete(handle_rrule=True, rrule_mode="thisandfuture") all_todos = c.todos(include_completed=True) if self.check_compatibility_flag("rrule_takes_no_count"): assert len(c.todos()) == 1 assert len(all_todos) == 1 self.skip_on_compatibility_flag("rrule_takes_no_count") assert len(c.todos()) == 2 assert len(all_todos) == 2 # assert sum([len(x.icalendar_instance.subcomponents) for x in all_todos]) == 5 t8.complete(handle_rrule=True, rrule_mode="thisandfuture") assert len(c.todos()) == 2 t8.complete(handle_rrule=True, rrule_mode="thisandfuture") t8.complete(handle_rrule=True, rrule_mode="thisandfuture") assert len(c.todos()) == 1 def testUtf8Event(self): # TODO: what's the difference between this and testUnicodeEvent? # TODO: split up in creating a calendar with non-ascii name # and an event with non-ascii description self.skip_on_compatibility_flag("no_mkcalendar") c = self.principal.make_calendar(name="Yølp", cal_id=self.testcal_id) # add event e1 = c.save_event( ev1.replace("Bastille Day Party", "Bringebærsyltetøyfestival") ) # fetch it back events = c.events() # no todos should be added if not self.check_compatibility_flag("no_todo"): todos = c.todos() assert len(todos) == 0 # COMPATIBILITY PROBLEM - todo, look more into it if "zimbra" not in str(c.url): assert len(events) == 1 def testUnicodeEvent(self): self.skip_on_compatibility_flag("no_mkcalendar") c = self.principal.make_calendar(name="Yølp", cal_id=self.testcal_id) # add event e1 = c.save_event( to_str(ev1.replace("Bastille Day Party", "Bringebærsyltetøyfestival")) ) # c.events() should give a full list of events events = c.events() # COMPATIBILITY PROBLEM - todo, look more into it if "zimbra" not in str(c.url): assert len(events) == 1 def testSetCalendarProperties(self): self.skip_on_compatibility_flag("no_displayname") c = self._fixCalendar() assert c.url is not None ## TODO: there are more things in this test that ## should be run even if mkcalendar is not available. self.skip_on_compatibility_flag("no_mkcalendar") props = c.get_properties( [ dav.DisplayName(), ] ) assert "Yep" == props[dav.DisplayName.tag] # Creating a new calendar with different ID but with existing name # TODO: why do we do this? cc = self.principal.make_calendar("Yep", self.testcal_id2) cc.delete() c.set_properties( [ dav.DisplayName("hooray"), ] ) props = c.get_properties( [ dav.DisplayName(), ] ) assert props[dav.DisplayName.tag] == "hooray" ## calendar color and calendar order are extra properties not ## described by RFC5545, but anyway supported by quite some ## server implementations if self.check_compatibility_flag("calendar_color"): props = c.get_properties( [ ical.CalendarColor(), ] ) assert props[ical.CalendarColor.tag] != "sort of blueish" c.set_properties( [ ical.CalendarColor("blue"), ] ) props = c.get_properties( [ ical.CalendarColor(), ] ) assert props[ical.CalendarColor.tag] == "blue" if self.check_compatibility_flag("calendar_order"): props = c.get_properties( [ ical.CalendarOrder(), ] ) assert props[ical.CalendarOrder.tag] != "-434" c.set_properties( [ ical.CalendarOrder("12"), ] ) props = c.get_properties( [ ical.CalendarOrder(), ] ) assert props[ical.CalendarOrder.tag] == "12" def testLookupEvent(self): """ Makes sure we can add events and look them up by URL and ID """ # Create calendar c = self._fixCalendar() assert c.url is not None # add event e1 = c.save_event(ev1) assert e1.url is not None # Verify that we can look it up, both by URL and by ID if not self.check_compatibility_flag("event_by_url_is_broken"): e2 = c.event_by_url(e1.url) assert e2.instance.vevent.uid == e1.instance.vevent.uid assert e2.url == e1.url e3 = c.event_by_uid("20010712T182145Z-123401@example.com") assert e3.instance.vevent.uid == e1.instance.vevent.uid assert e3.url == e1.url # Knowing the URL of an event, we should be able to get to it # without going through a calendar object if not self.check_compatibility_flag("event_by_url_is_broken"): e4 = Event(client=self.caldav, url=e1.url) e4.load() assert e4.instance.vevent.uid == e1.instance.vevent.uid with pytest.raises(error.NotFoundError): c.event_by_uid("0") c.save_event(evr) with pytest.raises(error.NotFoundError): c.event_by_uid("0") def testCreateOverwriteDeleteEvent(self): """ Makes sure we can add events and delete them """ # Create calendar c = self._fixCalendar() assert c.url is not None # attempts on updating/overwriting a non-existing event should fail with pytest.raises(error.ConsistencyError): c.save_event(ev1, no_create=True) # no_create and no_overwrite is mutually exclusive, this will always # raise an error (unless the ical given is blank) with pytest.raises(error.ConsistencyError): c.save_event(ev1, no_create=True, no_overwrite=True) # add event e1 = c.save_event(ev1) if not self.check_compatibility_flag( "no_todo" ) and not self.check_compatibility_flag("no_todo_on_standard_calendar"): t1 = c.save_todo(todo) assert e1.url is not None if not self.check_compatibility_flag( "no_todo" ) and not self.check_compatibility_flag("no_todo_on_standard_calendar"): assert t1.url is not None if not self.check_compatibility_flag("event_by_url_is_broken"): assert c.event_by_url(e1.url).url == e1.url assert c.event_by_uid(e1.id).url == e1.url ## add same event again. As it has same uid, it should be overwritten ## (but some calendars may throw a "409 Conflict") if not self.check_compatibility_flag("no_overwrite"): e2 = c.save_event(ev1) if not self.check_compatibility_flag( "no_todo" ) and not self.check_compatibility_flag("no_todo_on_standard_calendar"): t2 = c.save_todo(todo) ## add same event with "no_create". Should work like a charm. e2 = c.save_event(ev1, no_create=True) if not self.check_compatibility_flag( "no_todo" ) and not self.check_compatibility_flag("no_todo_on_standard_calendar"): t2 = c.save_todo(todo, no_create=True) ## this should also work. e2.instance.vevent.summary.value = e2.instance.vevent.summary.value + "!" e2.save(no_create=True) if not self.check_compatibility_flag( "no_todo" ) and not self.check_compatibility_flag("no_todo_on_standard_calendar"): t2.instance.vtodo.summary.value = t2.instance.vtodo.summary.value + "!" t2.save(no_create=True) if not self.check_compatibility_flag("event_by_url_is_broken"): e3 = c.event_by_url(e1.url) assert e3.instance.vevent.summary.value == "Bastille Day Party!" ## "no_overwrite" should throw a ConsistencyError with pytest.raises(error.ConsistencyError): c.save_event(ev1, no_overwrite=True) if not self.check_compatibility_flag( "no_todo" ) and not self.check_compatibility_flag("no_todo_on_standard_calendar"): with pytest.raises(error.ConsistencyError): c.save_todo(todo, no_overwrite=True) # delete event e1.delete() if not self.check_compatibility_flag( "no_todo" ) and not self.check_compatibility_flag("no_todo_on_standard_calendar"): t1.delete if self.check_compatibility_flag("non_existing_raises_other"): expected_error = error.DAVError else: expected_error = error.NotFoundError # Verify that we can't look it up, both by URL and by ID with pytest.raises(self._notFound()): c.event_by_url(e1.url) if not self.check_compatibility_flag("no_overwrite"): with pytest.raises(self._notFound()): c.event_by_url(e2.url) if not self.check_compatibility_flag("event_by_url_is_broken"): with pytest.raises(error.NotFoundError): c.event_by_uid("20010712T182145Z-123401@example.com") def testDateSearchAndFreeBusy(self): """ Verifies that date search works with a non-recurring event Also verifies that it's possible to change a date of a non-recurring event """ # Create calendar, add event ... c = self._fixCalendar() assert c.url is not None e = c.save_event(ev1) ## just a sanity check to increase coverage (ref ## https://github.com/python-caldav/caldav/issues/93) - ## expand=False and no end date given is no-no with pytest.raises(error.DAVError): c.date_search(datetime(2006, 7, 13, 17, 00, 00), expand=True) # .. and search for it. r1 = c.date_search( datetime(2006, 7, 13, 17, 00, 00), datetime(2006, 7, 15, 17, 00, 00), expand=False, ) r2 = c.search( event=True, start=datetime(2006, 7, 13, 17, 00, 00), end=datetime(2006, 7, 15, 17, 00, 00), expand=False, ) assert e.instance.vevent.uid == r1[0].instance.vevent.uid assert e.instance.vevent.uid == r2[0].instance.vevent.uid assert len(r1) == 1 assert len(r2) == 1 ## The rest of the test code here depends on us changing an event. ## Apparently, in google calendar, events are immutable. ## TODO: delete the old event and insert a new one rather than skipping. ## (But events should not be immutable! One should be able to change an event, push the changes ## out to all participants and all copies of the calendar, and let everyone know that it's a ## changed event and not a cancellation and a new event). self.skip_on_compatibility_flag("no_overwrite") # ev2 is same UID, but one year ahead. # The timestamp should change. e.data = ev2 e.save() r1 = c.date_search( datetime(2006, 7, 13, 17, 00, 00), datetime(2006, 7, 15, 17, 00, 00), expand=False, ) r2 = c.search( event=True, start=datetime(2006, 7, 13, 17, 00, 00), end=datetime(2006, 7, 15, 17, 00, 00), expand=False, ) assert len(r1) == 0 assert len(r2) == 0 r1 = c.date_search( datetime(2007, 7, 13, 17, 00, 00), datetime(2007, 7, 15, 17, 00, 00), expand=False, ) assert len(r1) == 1 # date search without closing date should also find it r = c.date_search(datetime(2007, 7, 13, 17, 00, 00), expand=False) assert len(r) == 1 # Lets try a freebusy request as well self.skip_on_compatibility_flag("no_freebusy_rfc4791") freebusy = c.freebusy_request( datetime(2007, 7, 13, 17, 00, 00), datetime(2007, 7, 15, 17, 00, 00) ) # TODO: assert something more complex on the return object assert isinstance(freebusy, FreeBusy) assert freebusy.instance.vfreebusy def testRecurringDateSearch(self): """ This is more sanity testing of the server side than testing of the library per se. How will it behave if we serve it a recurring event? """ self.skip_on_compatibility_flag("no_recurring") c = self._fixCalendar() # evr is a yearly event starting at 1997-02-11 e = c.save_event(evr) ## Without "expand", we should still find it when searching over 2008 ... r = c.date_search( datetime(2008, 11, 1, 17, 00, 00), datetime(2008, 11, 3, 17, 00, 00), expand=False, ) r2 = c.search( event=True, start=datetime(2008, 11, 1, 17, 00, 00), end=datetime(2008, 11, 3, 17, 00, 00), expand=False, ) assert len(r) == 1 assert len(r2) == 1 ## With expand=True, we should find one occurrence r1 = c.date_search( datetime(2008, 11, 1, 17, 00, 00), datetime(2008, 11, 3, 17, 00, 00), expand=True, ) r2 = c.search( event=True, start=datetime(2008, 11, 1, 17, 00, 00), end=datetime(2008, 11, 3, 17, 00, 00), expand=True, ) assert len(r1) == 1 assert len(r2) == 1 assert r1[0].data.count("END:VEVENT") == 1 assert r2[0].data.count("END:VEVENT") == 1 ## due to expandation, the DTSTART should be in 2008 assert r1[0].data.count("DTSTART;VALUE=DATE:2008") == 1 assert r2[0].data.count("DTSTART;VALUE=DATE:2008") == 1 ## With expand=True and searching over two recurrences ... r1 = c.date_search( datetime(2008, 11, 1, 17, 00, 00), datetime(2009, 11, 3, 17, 00, 00), expand=True, ) r2 = c.search( event=True, start=datetime(2008, 11, 1, 17, 00, 00), end=datetime(2009, 11, 3, 17, 00, 00), expand=True, ) ## According to https://tools.ietf.org/html/rfc4791#section-7.8.3, the ## resultset should be one vcalendar with two events. assert len(r1) == 1 assert "RRULE" not in r1[0].data assert r1[0].data.count("END:VEVENT") == 2 ## However, the new search method will by default split it into ## two events assert len(r2) == 2 assert "RRULE" not in r2[0].data assert "RRULE" not in r2[1].data assert r2[0].data.count("END:VEVENT") == 1 assert r2[1].data.count("END:VEVENT") == 1 # The recurring events should not be expanded when using the # events() method r = c.events() if not self.check_compatibility_flag("no_mkcalendar"): assert len(r) == 1 assert r[0].data.count("END:VEVENT") == 1 def testOffsetURL(self): """ pass an URL pointing to a calendar or a user to the DAVClient class, and things should still work """ urls = [self.principal.url, self._fixCalendar().url] connect_params = self.server_params.copy() connect_params.pop("url") for url in urls: conn = client(**connect_params, url=url) principal = conn.principal() calendars = principal.calendars() ## TODO: run this test, ref https://github.com/python-caldav/caldav/issues/91 ## It should be removed prior to a 1.0-release. def testBackwardCompatibility(self): """ Tobias Brox has done some API changes - but this thing should still be backward compatible. """ if "backwards_compatibility_url" not in self.server_params: pytest.skip( "backward compatibility check skipped - needs an URL like it was supposed to be in 2013" ) caldav = DAVClient(self.server_params["backwards_compatibility_url"]) principal = Principal(caldav, self.server_params["backwards_compatibility_url"]) c = Calendar(caldav, name="Yep", parent=principal, id=self.testcal_id).save() assert c.url is not None c.set_properties( [ dav.DisplayName("hooray"), ] ) props = c.get_properties( [ dav.DisplayName(), ] ) assert props[dav.DisplayName.tag] == "hooray" cc = Calendar(caldav, name="Yep", parent=principal).save() assert cc.url is not None cc.delete() e = Event(caldav, data=ev1, parent=c).save() assert e.url is not None ee = Event(caldav, url=url.make(e.url), parent=c) ee.load() assert e.instance.vevent.uid == ee.instance.vevent.uid r = c.date_search( datetime(2006, 7, 13, 17, 00, 00), datetime(2006, 7, 15, 17, 00, 00), expand=False, ) assert e.instance.vevent.uid == r[0].instance.vevent.uid assert len(r) == 1 all = c.events() assert len(all) == 1 e2 = Event(caldav, data=ev2, parent=c).save() assert e.url is not None tmp = c.event("20010712T182145Z-123401@example.com") assert e2.instance.vevent.uid == tmp.instance.vevent.uid r = c.date_search( datetime(2007, 7, 13, 17, 00, 00), datetime(2007, 7, 15, 17, 00, 00), expand=False, ) assert len(r) == 1 e.data = ev2 e.save() r = c.date_search( datetime(2007, 7, 13, 17, 00, 00), datetime(2007, 7, 15, 17, 00, 00), expand=False, ) # for e in r: print(e.data) assert len(r) == 1 e.instance = e2.instance e.save() r = c.date_search( datetime(2007, 7, 13, 17, 00, 00), datetime(2007, 7, 15, 17, 00, 00), expand=False, ) # for e in r: print(e.data) assert len(r) == 1 def testObjects(self): # TODO: description ... what are we trying to test for here? o = DAVObject(self.caldav) with pytest.raises(Exception): o.save() # We want to run all tests in the above class through all caldav_servers; # and I don't really want to create a custom nose test loader. The # solution here seems to be to generate one child class for each # caldav_url, and inject it into the module namespace. TODO: This is # very hacky. If there are better ways to do it, please let me know. # (maybe a custom nose test loader really would be the better option?) # -- Tobias Brox , 2013-10-10 _servernames = set() for _caldav_server in caldav_servers: if not _caldav_server.get("enable", True): continue # create a unique identifier out of the server domain name _parsed_url = urlparse(_caldav_server["url"]) _servername = _parsed_url.hostname.replace(".", "_").replace("-", "_") + str( _parsed_url.port or "" ) while _servername in _servernames: _servername = _servername + "_" _servernames.add(_servername) # create a classname and a class _classname = "TestForServer_" + _servername # inject the new class into this namespace vars()[_classname] = type( _classname, (RepeatedFunctionalTestsBaseClass,), {"server_params": _caldav_server}, ) class TestLocalRadicale(RepeatedFunctionalTestsBaseClass): """ Sets up a local Radicale server and runs the functional tests towards it """ def setup_method(self): if not test_radicale: pytest.skip("Skipping Radicale test due to configuration") self.serverdir = tempfile.TemporaryDirectory() self.serverdir.__enter__() self.configuration = radicale.config.load("") self.configuration.update( {"storage": {"filesystem_folder": self.serverdir.name}} ) self.server = radicale.server self.server_params = { "url": "http://%s:%i/" % (radicale_host, radicale_port), "username": "user1", "password": "password1", } self.server_params["backwards_compatibility_url"] = ( self.server_params["url"] + "user1" ) self.server_params["incompatibilities"] = compatibility_issues.radicale self.shutdown_socket, self.shutdown_socket_out = socket.socketpair() self.radicale_thread = threading.Thread( target=self.server.serve, args=(self.configuration, self.shutdown_socket_out), ) self.radicale_thread.start() i = 0 while True: try: requests.get(self.server_params["url"]) break except: time.sleep(0.05) i += 1 assert i < 100 try: RepeatedFunctionalTestsBaseClass.setup_method(self) except: logging.critical("something bad happened in setup", exc_info=True) self.teardown_method() def teardown_method(self): if not test_radicale: return self.shutdown_socket.close() i = 0 self.serverdir.__exit__(None, None, None) RepeatedFunctionalTestsBaseClass.teardown_method(self) class TestLocalXandikos(RepeatedFunctionalTestsBaseClass): """ Sets up a local Xandikos server and runs the functional tests towards it """ def setup_method(self): if not test_xandikos: pytest.skip("Skipping Xadikos test due to configuration") ## TODO: https://github.com/jelmer/xandikos/issues/131#issuecomment-1054805270 suggests a simpler way to launch the xandikos server self.serverdir = tempfile.TemporaryDirectory() self.serverdir.__enter__() ## Most of the stuff below is cargo-cult-copied from xandikos.web.main ## Later jelmer created some API that could be used for this ## Threshold put high due to https://github.com/jelmer/xandikos/issues/235 ## index_threshold not supported in latest release yet # self.backend = XandikosBackend(path=self.serverdir.name, index_threshold=0, paranoid=True) # self.backend = XandikosBackend(path=self.serverdir.name, index_threshold=9999, paranoid=True) self.backend = XandikosBackend(path=self.serverdir.name) self.backend._mark_as_principal("/sometestuser/") self.backend.create_principal("/sometestuser/", create_defaults=True) mainapp = XandikosApp( self.backend, current_user_principal="sometestuser", strict=True ) async def xandikos_handler(request): return await mainapp.aiohttp_handler(request, "/") self.xapp = aiohttp.web.Application() self.xapp.router.add_route("*", "/{path_info:.*}", xandikos_handler) ## https://stackoverflow.com/questions/51610074/how-to-run-an-aiohttp-server-in-a-thread self.xapp_loop = asyncio.new_event_loop() self.xapp_runner = aiohttp.web.AppRunner(self.xapp) asyncio.set_event_loop(self.xapp_loop) self.xapp_loop.run_until_complete(self.xapp_runner.setup()) self.xapp_site = aiohttp.web.TCPSite( self.xapp_runner, host=xandikos_host, port=xandikos_port ) self.xapp_loop.run_until_complete(self.xapp_site.start()) def aiohttp_server(): self.xapp_loop.run_forever() self.xandikos_thread = threading.Thread(target=aiohttp_server) self.xandikos_thread.start() self.server_params = {"url": "http://%s:%i/" % (xandikos_host, xandikos_port)} self.server_params["backwards_compatibility_url"] = ( self.server_params["url"] + "sometestuser" ) self.server_params["incompatibilities"] = compatibility_issues.xandikos RepeatedFunctionalTestsBaseClass.setup_method(self) def teardown_method(self): if not test_xandikos: return self.xapp_loop.stop() ## ... but the thread may be stuck waiting for a request ... def silly_request(): try: requests.get(self.server_params["url"]) except: pass threading.Thread(target=silly_request).start() i = 0 while self.xapp_loop.is_running(): time.sleep(0.05) i += 1 assert i < 100 self.xapp_loop.run_until_complete(self.xapp_runner.cleanup()) i = 0 while self.xandikos_thread.is_alive(): time.sleep(0.05) i += 1 assert i < 100 self.serverdir.__exit__(None, None, None) RepeatedFunctionalTestsBaseClass.teardown_method(self) caldav-0.11.0/tests/test_caldav_unit.py000066400000000000000000001235101433711056000200710ustar00rootroot00000000000000#!/usr/bin/env python # -*- encoding: utf-8 -*- """ Rule: None of the tests in this file should initiate any internet communication, and there should be no dependencies on a working caldav server for the tests in this file. We use the Mock class when needed to emulate server communication. """ import pickle from datetime import date from datetime import datetime from datetime import timedelta import caldav import icalendar import lxml.etree import pytest import vobject from caldav.davclient import DAVClient from caldav.davclient import DAVResponse from caldav.elements import cdav from caldav.elements import dav from caldav.elements import ical from caldav.lib import error from caldav.lib import url from caldav.lib import vcal from caldav.lib.python_utilities import to_normal_str from caldav.lib.python_utilities import to_wire from caldav.lib.url import URL from caldav.objects import Calendar from caldav.objects import CalendarObjectResource from caldav.objects import CalendarSet from caldav.objects import DAVObject from caldav.objects import Event from caldav.objects import FreeBusy from caldav.objects import Journal from caldav.objects import Principal from caldav.objects import Todo from six import PY3 if PY3: from urllib.parse import urlparse from unittest import mock else: from urlparse import urlparse import mock ## Some example icalendar data copied from test_caldav.py ev1 = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VEVENT UID:20010712T182145Z-123401@example.com DTSTAMP:20060712T182145Z DTSTART:20060714T170000Z DTEND:20060715T040000Z SUMMARY:Bastille Day Party END:VEVENT END:VCALENDAR """ todo = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VTODO UID:20070313T123432Z-456553@example.com DTSTAMP:20070313T123432Z DUE;VALUE=DATE:20070501 SUMMARY:Submit Quebec Income Tax Return for 2006 CLASS:CONFIDENTIAL CATEGORIES:FAMILY,FINANCE STATUS:NEEDS-ACTION END:VTODO END:VCALENDAR""" todo_implicit_duration = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VTODO UID:20070313T123432Z-456553@example.com DTSTAMP:20070313T123432Z DTSTART;VALUE=DATE:20070425 DUE;VALUE=DATE:20070501 SUMMARY:Submit Quebec Income Tax Return for 2006 CLASS:CONFIDENTIAL CATEGORIES:FAMILY,FINANCE STATUS:NEEDS-ACTION END:VTODO END:VCALENDAR""" todo_explicit_duration = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VTODO UID:20070313T123432Z-456553@example.com DTSTAMP:20070313T123432Z DTSTART:20070425T160000Z DURATION:P5D SUMMARY:Submit Quebec Income Tax Return for 2006 CLASS:CONFIDENTIAL CATEGORIES:FAMILY,FINANCE STATUS:NEEDS-ACTION END:VTODO END:VCALENDAR""" journal = """ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VJOURNAL UID:19970901T130000Z-123405@example.com DTSTAMP:19970901T130000Z DTSTART;VALUE=DATE:19970317 SUMMARY:Staff meeting minutes DESCRIPTION:1. Staff meeting: Participants include Joe\\, Lisa and Bob. Aurora project plans were reviewed. There is currently no budget reserves for this project. Lisa will escalate to management. Next meeting on Tuesday.\n END:VJOURNAL END:VCALENDAR """ # example from http://www.rfc-editor.org/rfc/rfc5545.txt evr = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VEVENT UID:19970901T130000Z-123403@example.com DTSTAMP:19970901T130000Z DTSTART;VALUE=DATE:19971102 SUMMARY:Our Blissful Anniversary TRANSP:TRANSPARENT CLASS:CONFIDENTIAL CATEGORIES:ANNIVERSARY,PERSONAL,SPECIAL OCCASION RRULE:FREQ=YEARLY END:VEVENT END:VCALENDAR""" todo6 = """ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VTODO UID:19920901T130000Z-123408@host.com DTSTAMP:19920901T130000Z DTSTART:19920415T133000Z DUE:19920516T045959Z SUMMARY:Yearly Income Tax Preparation RRULE:FREQ=YEARLY CLASS:CONFIDENTIAL CATEGORIES:FAMILY,FINANCE PRIORITY:1 END:VTODO END:VCALENDAR""" def MockedDAVResponse(text): """ For unit testing - a mocked DAVResponse with some specific content """ resp = mock.MagicMock() resp.status_code = 207 resp.reason = "multistatus" resp.headers = {} resp.content = text return DAVResponse(resp) def MockedDAVClient(xml_returned): """ For unit testing - a mocked DAVClient returning some specific content every time a request is performed """ client = DAVClient(url="https://somwhere.in.the.universe.example/some/caldav/root") client.request = mock.MagicMock(return_value=MockedDAVResponse(xml_returned)) return client class TestExpandRRule: """ Tests the expand_rrule method """ def setup_method(self): cal_url = "http://me:hunter2@calendar.example:80/" client = DAVClient(url=cal_url) self.yearly = Event(client, data=evr) self.todo = Todo(client, data=todo6) def testZero(self): ## evr has rrule yearly and dtstart DTSTART 1997-11-02 ## This should cause 0 recurrences: self.yearly.expand_rrule(start=datetime(1998, 4, 4), end=datetime(1998, 10, 10)) assert len(self.yearly.icalendar_instance.subcomponents) == 0 def testOne(self): self.yearly.expand_rrule( start=datetime(1998, 10, 10), end=datetime(1998, 12, 12) ) assert len(self.yearly.icalendar_instance.subcomponents) == 1 assert not "RRULE" in self.yearly.icalendar_component assert "UID" in self.yearly.icalendar_component assert "RECURRENCE-ID" in self.yearly.icalendar_component def testThree(self): self.yearly.expand_rrule( start=datetime(1996, 10, 10), end=datetime(1999, 12, 12) ) assert len(self.yearly.icalendar_instance.subcomponents) == 3 data1 = self.yearly.icalendar_instance.subcomponents[0].to_ical() data2 = self.yearly.icalendar_instance.subcomponents[1].to_ical() assert data1.replace(b"199711", b"199811") == data2 ## TODO: unskip this one @pytest.mark.skip( "waiting for https://github.com/niccokunzmann/python-recurring-ical-events/issues/97" ) def testThreeTodo(self): self.todo.expand_rrule(start=datetime(1996, 10, 10), end=datetime(1999, 12, 12)) assert len(self.todo.icalendar_instance.subcomponents) == 3 data1 = self.todo.icalendar_instance.subcomponents[0].to_ical() data2 = self.todo.icalendar_instance.subcomponents[1].to_ical() assert data1.replace(b"199711", b"199811") == data2 def testSplit(self): self.yearly.expand_rrule( start=datetime(1996, 10, 10), end=datetime(1999, 12, 12) ) events = self.yearly.split_expanded() assert len(events) == 3 assert len(events[0].icalendar_instance.subcomponents) == 1 assert ( events[1].icalendar_component["UID"] == "19970901T130000Z-123403@example.com" ) class TestCalDAV: """ Test class for "pure" unit tests (small internal tests, testing that a small unit of code works as expected, without any third party dependencies, without accessing any caldav server) """ @mock.patch("caldav.davclient.requests.Session.request") def testRequestNonAscii(self, mocked): """ ref https://github.com/python-caldav/caldav/issues/83 """ mocked().status_code = 200 mocked().headers = {} cal_url = "http://me:hunter2@calendar.møøh.example:80/" client = DAVClient(url=cal_url) response = client.put("/foo/møøh/bar", "bringebærsyltetøy 北京 пиво", {}) assert response.status == 200 assert response.tree is None if PY3: response = client.put( "/foo/møøh/bar".encode("utf-8"), "bringebærsyltetøy 北京 пиво".encode("utf-8"), {}, ) else: response = client.put(u"/foo/møøh/bar", "bringebærsyltetøy 北京 пиво", {}) # fmt: skip assert response.status == 200 assert response.tree is None @mock.patch("caldav.davclient.requests.Session.request") def testEmptyXMLNoContentLength(self, mocked): """ ref https://github.com/python-caldav/caldav/issues/213 """ mocked().status_code = 200 mocked().headers = {"Content-Type": "text/xml"} mocked().content = "" client = DAVClient(url="AsdfasDF").request("/") @mock.patch("caldav.davclient.requests.Session.request") def testNonValidXMLNoContentLength(self, mocked): """ If XML is expected but nonvalid XML is given, an error should be raised """ mocked().status_code = 200 mocked().headers = {"Content-Type": "text/xml"} mocked().content = "this is not XML" client = DAVClient(url="AsdfasDF") with pytest.raises(lxml.etree.XMLSyntaxError): client.request("/") def testPathWithEscapedCharacters(self): xml = b""" /some/caldav/root/133bahgr6ohlo9ungq0it45vf8%40group.calendar.google.com/events/ HTTP/1.1 200 OK """ client = MockedDAVClient(xml) assert client.calendar( url="https://somwhere.in.the.universe.example/some/caldav/root/133bahgr6ohlo9ungq0it45vf8%40group.calendar.google.com/events/" ).get_supported_components() == ["VEVENT"] def testAbsoluteURL(self): """Version 0.7.0 does not handle responses with absolute URLs very well, ref https://github.com/python-caldav/caldav/pull/103""" ## none of this should initiate any communication client = DAVClient(url="http://cal.example.com/") principal = Principal(client=client, url="http://cal.example.com/home/bernard/") ## now, ask for the calendar_home_set, but first we need to mock up client.propfind mocked_response = mock.MagicMock() mocked_response.status_code = 207 mocked_response.reason = "multistatus" mocked_response.headers = {} mocked_response.content = """ http://cal.example.com/home/bernard/ http://cal.example.com/home/bernard/calendars/ HTTP/1.1 200 OK """ mocked_davresponse = DAVResponse(mocked_response) client.propfind = mock.MagicMock(return_value=mocked_davresponse) bernards_calendars = principal.calendar_home_set assert bernards_calendars.url == URL( "http://cal.example.com/home/bernard/calendars/" ) def testDateSearch(self): """ ## ref https://github.com/python-caldav/caldav/issues/133 """ xml = """ /principals/calendar/home@petroski.example.com/963/43B060B3-A023-48ED-B9E7-6FFD38D5073E.ics HTTP/1.1 200 OK HTTP/1.1 404 Not Found /principals/calendar/home@petroski.example.com/963/114A4E50-8835-42E1-8185-8A97567B5C1A.ics HTTP/1.1 200 OK HTTP/1.1 404 Not Found /principals/calendar/home@petroski.example.com/963/C20A8820-7156-4DD2-AD1D-17105D923145.ics HTTP/1.1 200 OK HTTP/1.1 404 Not Found """ client = MockedDAVClient(xml) calendar = Calendar( client, url="/principals/calendar/home@petroski.example.com/963/" ) results = calendar.date_search(datetime(2021, 2, 1), datetime(2021, 2, 7)) assert len(results) == 3 def testCalendar(self): """ Principal.calendar() and CalendarSet.calendar() should create Calendar objects without initiating any communication with the server. Calendar.event() should create Event object without initiating any communication with the server. DAVClient.__init__ also doesn't do any communication Principal.__init__ as well, if the principal_url is given Principal.calendar_home_set needs to be set or the server will be queried """ cal_url = "http://me:hunter2@calendar.example:80/" client = DAVClient(url=cal_url) principal = Principal(client, cal_url + "me/") principal.calendar_home_set = cal_url + "me/calendars/" # calendar_home_set is actually a CalendarSet object assert isinstance(principal.calendar_home_set, CalendarSet) calendar1 = principal.calendar(name="foo", cal_id="bar") calendar2 = principal.calendar_home_set.calendar(name="foo", cal_id="bar") calendar3 = principal.calendar(cal_id="bar") assert calendar1.url == calendar2.url assert calendar1.url == calendar3.url assert calendar1.url == "http://calendar.example:80/me/calendars/bar/" # principal.calendar_home_set can also be set to an object # This should be noop principal.calendar_home_set = principal.calendar_home_set calendar1 = principal.calendar(name="foo", cal_id="bar") assert calendar1.url == calendar2.url # When building a calendar from a relative URL and a client, # the relative URL should be appended to the base URL in the client calendar1 = Calendar(client, "someoneelse/calendars/main_calendar") calendar2 = Calendar( client, "http://me:hunter2@calendar.example:80/someoneelse/calendars/main_calendar", ) assert calendar1.url == calendar2.url def test_get_events_icloud(self): """ tests that some XML observed from the icloud returns 0 events found. """ xml = """ /17149682/calendars/testcalendar-485d002e-31b9-4147-a334-1d71503a4e2c/ HTTP/1.1 200 OK HTTP/1.1 404 Not Found """ client = MockedDAVClient(xml) calendar = Calendar( client, url="/17149682/calendars/testcalendar-485d002e-31b9-4147-a334-1d71503a4e2c/", ) assert len(calendar.events()) == 0 def test_get_calendars(self): xml = """ /dav/tobias%40redpill-linpro.com/ HTTP/1.1 200 OK USER_ROOT /dav/tobias%40redpill-linpro.com/Inbox/ HTTP/1.1 200 OK Inbox /dav/tobias%40redpill-linpro.com/Emailed%20Contacts/ HTTP/1.1 200 OK Emailed Contacts /dav/tobias%40redpill-linpro.com/Calendarc5f1a47c-2d92-11e3-b654-0016eab36bf4.ics HTTP/1.1 200 OK Calendarc5f1a47c-2d92-11e3-b654-0016eab36bf4.ics /dav/tobias%40redpill-linpro.com/Yep/ HTTP/1.1 200 OK Yep """ client = MockedDAVClient(xml) calendar_home_set = CalendarSet(client, url="/dav/tobias%40redpill-linpro.com/") assert len(calendar_home_set.calendars()) == 1 def test_supported_components(self): xml = """ /17149682/calendars/testcalendar-0da571c7-139c-479a-9407-8ce9ed20146d/ HTTP/1.1 200 OK """ client = MockedDAVClient(xml) assert Calendar( client=client, url="/17149682/calendars/testcalendar-0da571c7-139c-479a-9407-8ce9ed20146d/", ).get_supported_components() == ["VEVENT"] def test_xml_parsing(self): """ DAVResponse has quite some code to parse the XML received from the server. This test contains real XML received from various caldav servers, and the expected result from the parse methods. """ xml = """ / /17149682/principal/ HTTP/1.1 200 OK """ expected_result = { "/": {"{DAV:}current-user-principal": "/17149682/principal/"} } assert ( MockedDAVResponse(xml).expand_simple_props( props=[dav.CurrentUserPrincipal()] ) == expected_result ) ## This duplicated response is observed in the real world - ## see https://github.com/python-caldav/caldav/issues/136 ## (though I suppose there was an email address instead of ## simply "frank", the XML I got was obfuscated) xml = """ /principals/users/frank/ /principals/users/frank/ HTTP/1.1 200 OK /principals/users/frank/ /principals/users/frank/ HTTP/1.1 200 OK """ expected_result = { "/principals/users/frank/": { "{DAV:}current-user-principal": "/principals/users/frank/" } } assert ( MockedDAVResponse(xml).expand_simple_props( props=[dav.CurrentUserPrincipal()] ) == expected_result ) xml = """ /17149682/principal/ https://p62-caldav.icloud.com:443/17149682/calendars/ HTTP/1.1 200 OK """ expected_result = { "/17149682/principal/": { "{urn:ietf:params:xml:ns:caldav}calendar-home-set": "https://p62-caldav.icloud.com:443/17149682/calendars/" } } assert ( MockedDAVResponse(xml).expand_simple_props(props=[cdav.CalendarHomeSet()]) == expected_result ) xml = """ / /17149682/principal/ HTTP/1.1 200 OK """ expected_result = { "/": {"{DAV:}current-user-principal": "/17149682/principal/"} } assert ( MockedDAVResponse(xml).expand_simple_props( props=[dav.CurrentUserPrincipal()] ) == expected_result ) xml = """ /17149682/calendars/testcalendar-84439d0b-ce46-4416-b978-7b4009122c64/ HTTP/1.1 200 OK HTTP/1.1 404 Not Found /17149682/calendars/testcalendar-84439d0b-ce46-4416-b978-7b4009122c64/20010712T182145Z-123401%40example.com.ics BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VEVENT UID:20010712T182145Z-123401@example.com DTSTAMP:20060712T182145Z DTSTART:20060714T170000Z DTEND:20060715T040000Z SUMMARY:Bastille Day Party END:VEVENT END:VCALENDAR HTTP/1.1 200 OK """ expected_result = { "/17149682/calendars/testcalendar-84439d0b-ce46-4416-b978-7b4009122c64/": { "{urn:ietf:params:xml:ns:caldav}calendar-data": None }, "/17149682/calendars/testcalendar-84439d0b-ce46-4416-b978-7b4009122c64/20010712T182145Z-123401@example.com.ics": { "{urn:ietf:params:xml:ns:caldav}calendar-data": "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Example Corp.//CalDAV Client//EN\nBEGIN:VEVENT\nUID:20010712T182145Z-123401@example.com\nDTSTAMP:20060712T182145Z\nDTSTART:20060714T170000Z\nDTEND:20060715T040000Z\nSUMMARY:Bastille Day Party\nEND:VEVENT\nEND:VCALENDAR\n" }, } assert ( MockedDAVResponse(xml).expand_simple_props(props=[cdav.CalendarData()]) == expected_result ) xml = """ /17149682/calendars/ Ny Test HTTP/1.1 200 OK /17149682/calendars/06888b87-397f-11eb-943b-3af9d3928d42/ calfoo3 HTTP/1.1 200 OK /17149682/calendars/inbox/ HTTP/1.1 200 OK HTTP/1.1 404 Not Found /17149682/calendars/testcalendar-e2910e0a-feab-4b51-b3a8-55828acaa912/ Yep HTTP/1.1 200 OK """ expected_result = { "/17149682/calendars/": { "{DAV:}resourcetype": ["{DAV:}collection"], "{DAV:}displayname": "Ny Test", }, "/17149682/calendars/06888b87-397f-11eb-943b-3af9d3928d42/": { "{DAV:}resourcetype": [ "{DAV:}collection", "{urn:ietf:params:xml:ns:caldav}calendar", ], "{DAV:}displayname": "calfoo3", }, "/17149682/calendars/inbox/": { "{DAV:}resourcetype": [ "{DAV:}collection", "{urn:ietf:params:xml:ns:caldav}schedule-inbox", ], "{DAV:}displayname": None, }, "/17149682/calendars/testcalendar-e2910e0a-feab-4b51-b3a8-55828acaa912/": { "{DAV:}resourcetype": [ "{DAV:}collection", "{urn:ietf:params:xml:ns:caldav}calendar", ], "{DAV:}displayname": "Yep", }, } assert ( MockedDAVResponse(xml).expand_simple_props( props=[dav.DisplayName()], multi_value_props=[dav.ResourceType()] ) == expected_result ) xml = """ /17149682/calendars/testcalendar-f96b3bf0-09e1-4f3d-b891-3a25c99a2894/ "kkkgopik" HTTP/1.1 200 OK /17149682/calendars/testcalendar-f96b3bf0-09e1-4f3d-b891-3a25c99a2894/1761bf8c-6363-11eb-8fe4-74e5f9bfd8c1.ics "kkkgorwx" HTTP/1.1 200 OK /17149682/calendars/testcalendar-f96b3bf0-09e1-4f3d-b891-3a25c99a2894/20010712T182145Z-123401%40example.com.ics "kkkgoqqu" HTTP/1.1 200 OK HwoQEgwAAAh4yw8ntwAAAAAYAhgAIhUIopml463FieB4EKq9+NSn04DrkQEoAA== """ expected_results = { "/17149682/calendars/testcalendar-f96b3bf0-09e1-4f3d-b891-3a25c99a2894/": { "{DAV:}getetag": '"kkkgopik"', "{urn:ietf:params:xml:ns:caldav}calendar-data": None, }, "/17149682/calendars/testcalendar-f96b3bf0-09e1-4f3d-b891-3a25c99a2894/1761bf8c-6363-11eb-8fe4-74e5f9bfd8c1.ics": { "{DAV:}getetag": '"kkkgorwx"', "{urn:ietf:params:xml:ns:caldav}calendar-data": None, }, "/17149682/calendars/testcalendar-f96b3bf0-09e1-4f3d-b891-3a25c99a2894/20010712T182145Z-123401@example.com.ics": { "{DAV:}getetag": '"kkkgoqqu"', "{urn:ietf:params:xml:ns:caldav}calendar-data": None, }, } def testFailedQuery(self): """ ref https://github.com/python-caldav/caldav/issues/54 """ cal_url = "http://me:hunter2@calendar.example:80/" client = DAVClient(url=cal_url) calhome = CalendarSet(client, cal_url + "me/") ## syntesize a failed response class FailedResp: pass failedresp = FailedResp() failedresp.status = 400 failedresp.reason = "you are wrong" failedresp.raw = "your request does not adhere to standards" ## synthesize a new http method calhome.client.unknown_method = lambda url, body, depth: failedresp ## call it. with pytest.raises(error.DAVError): calhome._query(query_method="unknown_method") def testDefaultClient(self): """When no client is given to a DAVObject, but the parent is given, parent.client will be used""" cal_url = "http://me:hunter2@calendar.example:80/" client = DAVClient(url=cal_url) calhome = CalendarSet(client, cal_url + "me/") calendar = Calendar(parent=calhome) assert calendar.client == calhome.client def testData(self): """ Event.data should always return a unicode string, without \r Event.wire_data should always return a byte string, with \r\n """ cal_url = "http://me:hunter2@calendar.example:80/" client = DAVClient(url=cal_url) my_event = Event(client, data=ev1) ## bytes on py3, normal string on py2 (but nobody uses python2, I hope?) bytestr = b"".__class__ assert isinstance(my_event.data, str) assert isinstance(my_event.wire_data, bytestr) ## this may have side effects, as it converts the internal storage my_event.icalendar_instance assert isinstance(my_event.data, str) assert isinstance(my_event.wire_data, bytestr) ## this may have side effects, as it converts the internal storage my_event.vobject_instance assert isinstance(my_event.data, str) assert isinstance(my_event.wire_data, bytestr) my_event.wire_data = to_wire(ev1) assert isinstance(my_event.data, str) assert isinstance(my_event.wire_data, bytestr) my_event.data = to_normal_str(ev1) assert isinstance(my_event.data, str) assert isinstance(my_event.wire_data, bytestr) def testInstance(self): cal_url = "http://me:hunter2@calendar.example:80/" client = DAVClient(url=cal_url) my_event = Event(client, data=ev1) my_event.vobject_instance.vevent.summary.value = "new summary" assert "new summary" in my_event.data icalobj = my_event.icalendar_instance icalobj.subcomponents[0]["SUMMARY"] = "yet another summary" assert my_event.vobject_instance.vevent.summary.value == "yet another summary" ## Now the data has been converted from string to vobject to string to icalendar to string to vobject and ... will the string still match the original? lines_now = my_event.data.strip().split("\n") lines_orig = ( ev1.replace("Bastille Day Party", "yet another summary").strip().split("\n") ) lines_now.sort() lines_orig.sort() assert lines_now == lines_orig def testComponent(self): cal_url = "http://me:hunter2@calendar.example:80/" client = DAVClient(url=cal_url) my_event = Event(client, data=ev1) icalcomp = my_event.icalendar_component icalcomp["SUMMARY"] = "yet another summary" assert my_event.vobject_instance.vevent.summary.value == "yet another summary" ## will the string still match the original? lines_now = my_event.data.strip().split("\n") lines_orig = ( ev1.replace("Bastille Day Party", "yet another summary").strip().split("\n") ) lines_now.sort() lines_orig.sort() assert lines_now == lines_orig ## Can we replace the component? (One shouldn't do things like this in normal circumstances though ... both because the uid changes and because the component type changes - we're putting a vtodo into an Event class ...) icalendar_component = icalendar.Todo.from_ical(todo).subcomponents[0] my_event.icalendar_component = icalendar_component assert ( my_event.vobject_instance.vtodo.summary.value == "Submit Quebec Income Tax Return for 2006" ) def testTodoDuration(self): cal_url = "http://me:hunter2@calendar.example:80/" client = DAVClient(url=cal_url) my_todo1 = Todo(client, data=todo) my_todo2 = Todo(client, data=todo_implicit_duration) my_todo3 = Todo(client, data=todo_explicit_duration) assert my_todo1.get_duration() == timedelta(0) assert my_todo1.get_due() == date(2007, 5, 1) assert my_todo2.get_duration() == timedelta(days=6) assert my_todo2.get_due() == date(2007, 5, 1) assert my_todo3.get_duration() == timedelta(days=5) foo6 = my_todo3.get_due().strftime("%s") == "1177945200" some_date = date(2011, 1, 1) my_todo1.set_due(some_date) assert my_todo1.get_due() == some_date ## set_due has "only" one if, so two code paths, one where dtstart is actually moved and one where it isn't my_todo2.set_due(some_date, move_dtstart=True) assert my_todo2.icalendar_instance.subcomponents[0][ "DTSTART" ].dt == some_date - timedelta(days=6) ## set_duration at the other hand has 5 code paths ... ## 1) DUE and DTSTART set, DTSTART as the movable component my_todo1.set_duration(timedelta(1)) assert my_todo1.get_due() == some_date assert my_todo1.icalendar_instance.subcomponents[0][ "DTSTART" ].dt == some_date - timedelta(1) ## 2) DUE and DTSTART set, DUE as the movable component my_todo1.set_duration(timedelta(2), movable_attr="DUE") assert my_todo1.get_due() == some_date + timedelta(days=1) assert my_todo1.icalendar_instance.subcomponents[0][ "DTSTART" ].dt == some_date - timedelta(1) ## 3) DUE set, DTSTART not set dtstart = my_todo1.icalendar_instance.subcomponents[0].pop("DTSTART").dt my_todo1.set_duration(timedelta(2)) assert my_todo1.icalendar_instance.subcomponents[0]["DTSTART"].dt == dtstart ## 4) DTSTART set, DUE not set my_todo1.icalendar_instance.subcomponents[0].pop("DUE") my_todo1.set_duration(timedelta(1)) assert my_todo1.get_due() == some_date ## 5) Neither DUE nor DTSTART set my_todo1.icalendar_instance.subcomponents[0].pop("DUE") my_todo1.icalendar_instance.subcomponents[0].pop("DTSTART") my_todo1.set_duration(timedelta(days=3)) assert my_todo1.get_duration() == timedelta(days=3) def testURL(self): """Exercising the URL class""" long_url = "http://foo:bar@www.example.com:8080/caldav.php/?foo=bar" # 1) URL.objectify should return a valid URL object almost no matter # what's thrown in url0 = URL.objectify(None) url0b = URL.objectify("") url1 = URL.objectify(long_url) url2 = URL.objectify(url1) url3 = URL.objectify("/bar") url4 = URL.objectify(urlparse(str(url1))) url5 = URL.objectify(urlparse("/bar")) # 2) __eq__ works well assert url1 == url2 assert url1 == url4 assert url3 == url5 # 3) str will always return the URL assert str(url1) == long_url assert str(url3) == "/bar" assert str(url4) == long_url assert str(url5) == "/bar" ## 3b) repr should also be exercised. Returns URL(/bar) now. assert "/bar" in repr(url5) assert "URL" in repr(url5) assert len(repr(url5)) < 12 # 4) join method url6 = url1.join(url2) url7 = url1.join(url3) url8 = url1.join(url4) url9 = url1.join(url5) urlA = url1.join("someuser/calendar") urlB = url5.join(url1) assert url6 == url1 assert url7 == "http://foo:bar@www.example.com:8080/bar" assert url8 == url1 assert url9 == url7 assert ( urlA == "http://foo:bar@www.example.com:8080/caldav.php/someuser/calendar" ) assert urlB == url1 with pytest.raises(ValueError): url1.join("http://www.google.com") # 4b) join method, with URL as input parameter url6 = url1.join(URL.objectify(url2)) url7 = url1.join(URL.objectify(url3)) url8 = url1.join(URL.objectify(url4)) url9 = url1.join(URL.objectify(url5)) urlA = url1.join(URL.objectify("someuser/calendar")) urlB = url5.join(URL.objectify(url1)) url6b = url6.join(url0) url6c = url6.join(url0b) url6d = url6.join(None) for url6alt in (url6b, url6c, url6d): assert url6 == url6alt assert url6 == url1 assert url7 == "http://foo:bar@www.example.com:8080/bar" assert url8 == url1 assert url9 == url7 assert ( urlA == "http://foo:bar@www.example.com:8080/caldav.php/someuser/calendar" ) assert urlB == url1 with pytest.raises(ValueError): url1.join("http://www.google.com") # 5) all urlparse methods will work. always. assert url1.scheme == "http" assert url2.path == "/caldav.php/" assert url7.username == "foo" assert url5.path == "/bar" urlC = URL.objectify("https://www.example.com:443/foo") assert urlC.port == 443 # 6) is_auth returns True if the URL contains a username. assert not urlC.is_auth() assert url7.is_auth() # 7) unauth() strips username/password assert url7.unauth() == "http://www.example.com:8080/bar" # 8) strip_trailing_slash assert URL("http://www.example.com:8080/bar/").strip_trailing_slash() == URL( "http://www.example.com:8080/bar" ) assert ( URL("http://www.example.com:8080/bar/").strip_trailing_slash() == URL("http://www.example.com:8080/bar").strip_trailing_slash() ) # 9) canonical assert ( URL("https://www.example.com:443/b%61r/").canonical() == URL("//www.example.com/bar/").canonical() ) # 10) pickle assert pickle.loads(pickle.dumps(url1)) == url1 def testFilters(self): filter = cdav.Filter().append( cdav.CompFilter("VCALENDAR").append( cdav.CompFilter("VEVENT").append( cdav.PropFilter("UID").append( [cdav.TextMatch("pouet", negate=True)] ) ) ) ) # print(filter) crash = cdav.CompFilter() value = None try: value = str(crash) except: pass if value is not None: raise Exception("This should have crashed") def test_vcal_fixups(self): """ There is an obscure function lib.vcal that attempts to fix up known ical standard breaches from various calendar servers. """ broken_ical = [ ## This first one contains duplicated DTSTART in the event data """BEGIN:VCALENDAR X-EXPANDED:True X-MASTER-DTSTART:20200517T060000Z X-MASTER-RRULE:FREQ=YEARLY BEGIN:VEVENT DTSTAMP:20210205T101751Z UID:20200516T060000Z-123401@example.com DTSTAMP:20200516T060000Z SUMMARY:Do the needful DTSTART:20210517T060000Z DTEND:20210517T230000Z RECURRENCE-ID:20210517T060000Z END:VEVENT BEGIN:VEVENT DTSTAMP:20210205T101751Z UID:20200516T060000Z-123401@example.com DTSTAMP:20200516T060000Z SUMMARY:Do the needful DTSTART:20220517T060000Z DTEND:20220517T230000Z RECURRENCE-ID:20220517T060000Z END:VEVENT BEGIN:VEVENT DTSTAMP:20210205T101751Z UID:20200516T060000Z-123401@example.com DTSTAMP:20200516T060000Z SUMMARY:Do the needful DTSTART:20230517T060000Z DTEND:20230517T230000Z RECURRENCE-ID:20230517T060000Z END:VEVENT END:VCALENDAR""" ] ## todo: add more broken ical here for ical in broken_ical: ## This should raise error with pytest.raises(vobject.base.ValidateError): vobject.readOne(ical).serialize() ## This should not raise error vobject.readOne(vcal.fix(ical)).serialize() def test_calendar_comp_class_by_data(self): calendar = Calendar() for (ical, class_) in ( (ev1, Event), (todo, Todo), (journal, Journal), (None, CalendarObjectResource), ("random rantings", CalendarObjectResource), ): ## TODO: freebusy, time zone assert calendar._calendar_comp_class_by_data(ical) == class_ if ical != "random rantings" and ical: assert ( calendar._calendar_comp_class_by_data( icalendar.Calendar.from_ical(ical) ) == class_ ) def testContextManager(self): """ ref https://github.com/python-caldav/caldav/pull/175 """ cal_url = "http://me:hunter2@calendar.example:80/" with DAVClient(url=cal_url) as client_ctx_mgr: assert isinstance(client_ctx_mgr, DAVClient) caldav-0.11.0/tests/test_cdav.py000066400000000000000000000036551433711056000165240ustar00rootroot00000000000000import datetime import pytz import tzlocal from caldav.elements.cdav import _to_utc_date_string from caldav.elements.cdav import CalendarQuery SOMEWHERE_REMOTE = pytz.timezone("Brazil/DeNoronha") # UTC-2 and no DST def test_element(): cq = CalendarQuery() assert str(cq).startswith("