pax_global_header 0000666 0000000 0000000 00000000064 14536110754 0014520 g ustar 00root root 0000000 0000000 52 comment=e48f173e7b479c6f942ab85185ea4166fbfd668d
caldav-1.3.9/ 0000775 0000000 0000000 00000000000 14536110754 0012764 5 ustar 00root root 0000000 0000000 caldav-1.3.9/.github/ 0000775 0000000 0000000 00000000000 14536110754 0014324 5 ustar 00root root 0000000 0000000 caldav-1.3.9/.github/workflows/ 0000775 0000000 0000000 00000000000 14536110754 0016361 5 ustar 00root root 0000000 0000000 caldav-1.3.9/.github/workflows/tests.yaml 0000664 0000000 0000000 00000003043 14536110754 0020407 0 ustar 00root root 0000000 0000000 ---
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.11'
- '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.11'
- 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.11'
- 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-1.3.9/.gitignore 0000664 0000000 0000000 00000000402 14536110754 0014750 0 ustar 00root root 0000000 0000000 .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-1.3.9/.pre-commit-config.yaml 0000664 0000000 0000000 00000000703 14536110754 0017245 0 ustar 00root root 0000000 0000000 ---
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-1.3.9/CHANGELOG.md 0000664 0000000 0000000 00000013255 14536110754 0014603 0 ustar 00root root 0000000 0000000 # Changelog
All notable changes to this project starting from v1.2 will be documented in this file.
Changelogs prior to v1.2 has been removed, but are available in the
v1.2-release. The project started with a GNU ChangeLog, but it was
useless and horrible to maintain. Then I made up my own kind of
changelogs for a while, until someone pointed me towards
https://keepachangelog.com. The format of this file is more or less
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project should more or less adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.3.9] - 2023-12-12
Some bugfixes.
### Fixes
* Some parts of the library would throw OverflowError on very weird dates/timestamps. Now those are converted to the minimum or maximum accepted date/timestamp. Credits to github user @tamarinvs19 in https://github.com/python-caldav/caldav/pull/327
* `DAVResponse.davclient` was always set to None, now it may be set to the `DAVClient` instance. Credits to github user @sobolevn in https://github.com/python-caldav/caldav/pull/323
* `DAVResponse.davclient` was always set to None, now it may be set to the `DAVClient` instance. Credits to github user @sobolevn in https://github.com/python-caldav/caldav/pull/323
* `examples/sync_examples.py`, the sync token needs to be saved to the database (credits to Savvas Giannoukas)
* Bugfixes in `set_relations`, credits to github user @Zocker1999NET in https://github.com/python-caldav/caldav/pull/335 and https://github.com/python-caldav/caldav/pull/333
* Dates that are off the scale are converted to `min_date` and `max_date` (and logging en error) rather than throwing OverflowError, credits to github user @tamarinvs19 in https://github.com/python-caldav/caldav/pull/327
* Completing a recurring task with a naïve or floating `DTSTART` would cause a runtime error
* Tests stopped working on python 3.7 and python 3.8 for a while. This was only an issue with libraries used for the testing, and has been mended.
* Bugfix that a 500 internal server error could cause an recursion loop, credits to github user @bchardin in https://github.com/python-caldav/caldav/pull/344
* Compatibility-fix for Google calendar, credits to github user @e-katov in https://github.com/python-caldav/caldav/pull/344
* Spelling, grammar and removing a useless regexp, credits to github user @scop in https://github.com/python-caldav/caldav/pull/337
* Faulty icalendar code caused the code for fixing faulty icalendar code to break, credits to github user @yuwash in https://github.com/python-caldav/caldav/pull/347 and https://github.com/python-caldav/caldav/pull/350
* Sorting on uppercase attributes didn't work, ref issue https://github.com/python-caldav/caldav/issues/352 - credits to github user @ArtemIsmagilov.
* The sorting algorithm was dependent on vobject library - refactored to use icalendar library instead
* Lots more test code on the sorting, and fixed some corner cases
* Creating a task with a status didn't work
## [1.3.8] - 2023-12-10 [YANKED]
Why do I never manage to do releases right ..
## [1.3.7] - 2023-12-10 [YANKED]
I managed to tag the wrong commit
## [1.3.6] - 2023-07-20
Very minor test fix
### Fixed
One of the tests has been partially disabled, ref https://github.com/python-caldav/caldav/issues/300 , https://github.com/python-caldav/caldav/issues/320 and https://github.com/python-caldav/caldav/pull/321
## [1.3.5] - 2023-07-19 [YANKED]
Seems like I've been using the wrong procedure all the time for doing pypi-releases
## [1.3.4] - 2023-07-19 [YANKED]
... Github has some features that it will merge pull requests only when all tests passes ... but somehow I can't get it to work, so 1.3.4 broke the style test again ...
## [1.3.3] - 2023-07-19
Summary: Some few workarounds to support yet more different calendar servers and cloud providers, some few minor enhancements needed by various contributors, and some minor bugfixes.
### Added
* Support for very big events, credits to github user @aaujon in https://github.com/python-caldav/caldav/pull/301
* Custom HTTP headers was added in v1.2, but documentation and unit test is added in v1.3, credits to github user @JasonSanDiego in https://github.com/python-caldav/caldav/pull/306
* More test code in https://github.com/python-caldav/caldav/pull/308
* Add props parameter to search function, credits to github user @ge-lem in https://github.com/python-caldav/caldav/pull/315
* Set an id field in calendar objects when populated through `CalendarSet.calendars()`, credits to github user @shikasta-net in https://github.com/python-caldav/caldav/pull/314
* `get_relatives`-method, https://github.com/python-caldav/caldav/pull/294
* `get_dtend`-method
### Fixed
* Bugfix in error handling, credits to github user @aaujon in https://github.com/python-caldav/caldav/pull/299
* Various minor bugfixes in https://github.com/python-caldav/caldav/pull/307
* Compatibility workaround for unknown caldav server in https://github.com/python-caldav/caldav/pull/303
* Google compatibility workaround, credits to github user @flozz in https://github.com/python-caldav/caldav/pull/312
* Documentation typos, credits to github user @FluxxCode in https://github.com/python-caldav/caldav/pull/317
* Improved support for cloud provider gmx.de in https://github.com/python-caldav/caldav/pull/318
### Changes
* Refactored relation handling in `set_due`
## [1.3.2] - 2023-07-19 [YANKED]
One extra line in CHANGELOG.md caused style tests to break. Can't have a release with broken tests. Why is it so hard for me to do releases correctly?
## [1.3.1] - 2023-07-19 [YANKED]
I forgot bumping the version number from 1.3.0 to 1.3.1 prior to tagging
## [1.3.0] - 2023-07-19 [YANKED]
I accidentally tagged the wrong stuff in the git repo
caldav-1.3.9/CONTRIBUTING.md 0000664 0000000 0000000 00000004205 14536110754 0015216 0 ustar 00root root 0000000 0000000 # Contributing
Contributions are mostly welcome. If the length of this text scares you, then I'd rather want you to skip reading and just produce a pull-request in GitHub.
## Considerations
* Contributions that break backward compatibility will (generally) not be accepted
* Primary scope of the library is to deal with the CalDAV-protocol. Just consider for a moment if your new feature may fit better into another library, like the icalendar library or the plann tool.
* Workarounds for supporting some quirky CalDAV-server is generally accepted, just be careful that your contribution does not break for other CalDAV-servers.
* If you need to deal with iCalendar payload, new code should do it through the icalendar library.
## Contribution Procedure
Consider this procedures to be a more of a guideline than a rigid procedure. Use your own judgement, skip steps you deem too difficult, too boring or that doesn't make sense. If you don't have an account at GitHub, then reach out by email to t-py-caldav@tobixen.no (prepend subject with `caldav:` and my spam filter will let it through).
* Write up an issue at [GitHub](https://github.com/python-caldav/caldav/issues/new)
* Create your own [GitHub fork](https://github.com/python-caldav/caldav/fork)
* Clone locally (`git clone https://github.com/$LOGNAME/caldav`) and run `pytest` for a quick run of the tests. They should pass (you may need to replace `$LOGNAME`).
* Write up some test code prior to changing the code ("test-driven development" is a good concept)
* Write up your changes
* Run `pytest` for a quick run of the tests. They should still pass.
* Run `tox -e style` to verify a consistent code style (this may modify your code).
* Consider to write some lines in the documentation and/or examples covering your change
* Add an entry in the `CHANGELOG.md` file.
* Create a pull request
```
## Code of Conduct
There is some text on https://www.contributor-covenant.org/, please DO reach out at t-py-caldav@tobixen.no if you notice a need for an explicit Code of Conduct.
Specific for this project, we should probably strive not to use too many negative adjectives on server implementations.
caldav-1.3.9/COPYING.APACHE 0000664 0000000 0000000 00000026136 14536110754 0014747 0 ustar 00root root 0000000 0000000
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-1.3.9/COPYING.GPL 0000664 0000000 0000000 00000104513 14536110754 0014444 0 ustar 00root root 0000000 0000000 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-1.3.9/MANIFEST.in 0000664 0000000 0000000 00000000127 14536110754 0014522 0 ustar 00root root 0000000 0000000 include COPYING.*
include changelog.0.8.md
include README.md
recursive-include tests *
caldav-1.3.9/Makefile 0000664 0000000 0000000 00000000610 14536110754 0014421 0 ustar 00root root 0000000 0000000 TOX_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-1.3.9/README.md 0000664 0000000 0000000 00000001173 14536110754 0014245 0 ustar 00root root 0000000 0000000 # 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-1.3.9/caldav/ 0000775 0000000 0000000 00000000000 14536110754 0014216 5 ustar 00root root 0000000 0000000 caldav-1.3.9/caldav/__init__.py 0000664 0000000 0000000 00000001037 14536110754 0016330 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python
import logging
import vobject.icalendar
__version__ = "1.3.9"
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 at some point
# Silence notification of no default logging handler
log = logging.getLogger("caldav")
class NullHandler(logging.Handler):
def emit(self, record):
pass
log.addHandler(NullHandler())
caldav-1.3.9/caldav/davclient.py 0000664 0000000 0000000 00000061647 14536110754 0016557 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python
# -*- encoding: utf-8 -*-
import logging
from urllib.parse import unquote
import requests
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 caldav.requests import HTTPBearerAuth
from lxml import etree
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
davclient = None
huge_tree = False
def __init__(self, response, davclient=None):
self.headers = response.headers
log.debug("response headers: " + str(self.headers))
log.debug("response status: " + str(self.status))
self._raw = response.content
self.davclient = davclient
if davclient:
self.huge_tree = davclient.huge_tree
## 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, huge_tree=self.huge_tree
),
)
except:
logging.critical(
"Expected some valid XML from the server, but got this: \n"
+ str(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, huge_tree=self.huge_tree
),
)
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
huge_tree = False
def __init__(
self,
url,
proxy=None,
username=None,
password=None,
auth=None,
timeout=None,
ssl_verify_cert=True,
ssl_cert=None,
headers={},
huge_tree=False,
):
"""
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.
* huge_tree: boolean, enable XMLParser huge_tree to handle big events, beware
of security issues, see : https://lxml.de/api/lxml.etree.XMLParser-class.html
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)
self.huge_tree = huge_tree
# Prepare proxy info
if proxy is not None:
self.proxy = proxy
# requests library expects the proxy url to have a scheme
if "://" not in proxy:
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 = headers
self.headers.update(
{
"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, a 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.split(" ")[0], auth_types)
return list(filter(lambda auth_type: auth_type, auth_types))
def request(self, url, method="GET", body="", headers={}):
"""
Actually sends the request, and does the authentication
"""
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, self)
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 not self.auth
and self.username
):
auth_types = self.extract_auth_types(r.headers["WWW-Authenticate"])
if self.password and self.username and "digest" in auth_types:
self.auth = requests.auth.HTTPDigestAuth(self.username, self.password)
elif self.password and self.username and "basic" in auth_types:
self.auth = requests.auth.HTTPBasicAuth(self.username, self.password)
elif self.password and "bearer" in auth_types:
self.auth = HTTPBearerAuth(self.password)
else:
raise NotImplementedError(
"The server does not provide any of the currently "
"supported authentication methods: basic, digest, bearer"
)
return self.request(url, method, body, headers)
elif (
r.status_code == 401
and "WWW-Authenticate" in r.headers
and self.auth
and self.password
and hasattr(self.password, "decode")
):
## Most likely we're here due to wrong username/password
## combo, but it could also be charset problems. 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 "digest" in auth_types:
self.auth = requests.auth.HTTPDigestAuth(
self.username, self.password.decode()
)
elif "basic" in auth_types:
self.auth = requests.auth.HTTPBasicAuth(
self.username, self.password.decode()
)
elif "bearer" in auth_types:
self.auth = HTTPBearerAuth(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-1.3.9/caldav/elements/ 0000775 0000000 0000000 00000000000 14536110754 0016032 5 ustar 00root root 0000000 0000000 caldav-1.3.9/caldav/elements/__init__.py 0000664 0000000 0000000 00000000060 14536110754 0020137 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python
# -*- encoding: utf-8 -*-
caldav-1.3.9/caldav/elements/base.py 0000664 0000000 0000000 00000003631 14536110754 0017321 0 ustar 00root root 0000000 0000000 #!/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
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
)
return str(utf8, "utf-8")
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-1.3.9/caldav/elements/cdav.py 0000664 0000000 0000000 00000012617 14536110754 0017330 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python
# -*- encoding: utf-8 -*-
import logging
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())
mindate = datetime.min.replace(tzinfo=utc_tz)
maxdate = datetime.max.replace(tzinfo=utc_tz)
if mindate + ts.tzinfo.utcoffset(ts) > ts:
logging.error(
"Cannot coerce datetime %s to UTC. Changed to min-date.", ts
)
ts = mindate
elif ts > maxdate - ts.tzinfo.utcoffset(ts):
logging.error(
"Cannot coerce datetime %s to UTC. Changed to max-date.", ts
)
ts = maxdate
else:
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-1.3.9/caldav/elements/dav.py 0000664 0000000 0000000 00000003111 14536110754 0017152 0 ustar 00root root 0000000 0000000 #!/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-1.3.9/caldav/elements/ical.py 0000664 0000000 0000000 00000000477 14536110754 0017324 0 ustar 00root root 0000000 0000000 #!/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-1.3.9/caldav/lib/ 0000775 0000000 0000000 00000000000 14536110754 0014764 5 ustar 00root root 0000000 0000000 caldav-1.3.9/caldav/lib/__init__.py 0000664 0000000 0000000 00000000000 14536110754 0017063 0 ustar 00root root 0000000 0000000 caldav-1.3.9/caldav/lib/debug.py 0000664 0000000 0000000 00000000347 14536110754 0016430 0 ustar 00root root 0000000 0000000 from lxml import etree
def xmlstring(root):
if hasattr(root, "xmlelement"):
root = root.xmlelement()
return etree.tostring(root, pretty_print=True).decode("utf-8")
def printxml(root):
print(xmlstring(root))
caldav-1.3.9/caldav/lib/error.py 0000664 0000000 0000000 00000004754 14536110754 0016501 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python
# -*- encoding: utf-8 -*-
import logging
from collections import defaultdict
from caldav import __version__
try:
import os
## one of DEBUG_PDB, DEBUG, DEVELOPMENT, PRODUCTION
debugmode = os.environ["PYTHON_CALDAV_DEBUGMODE"]
except:
if "dev" in __version__:
debugmode = "DEVELOPMENT"
else:
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-1.3.9/caldav/lib/namespace.py 0000664 0000000 0000000 00000001234 14536110754 0017272 0 ustar 00root root 0000000 0000000 #!/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-1.3.9/caldav/lib/python_utilities.py 0000664 0000000 0000000 00000001436 14536110754 0020756 0 ustar 00root root 0000000 0000000 def to_wire(text):
if text is None:
return None
if isinstance(text, str):
text = bytes(text, "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, str):
text = text.decode("utf-8")
text = text.replace("\r\n", "\n")
return text
to_str = to_local
def to_normal_str(text):
"""
Make sure we return a normal string
"""
if text is None:
return text
if not isinstance(text, str):
text = text.decode("utf-8")
text = text.replace("\r\n", "\n")
return text
def to_unicode(text):
if text and isinstance(text, bytes):
return text.decode("utf-8")
return text
caldav-1.3.9/caldav/lib/url.py 0000664 0000000 0000000 00000014515 14536110754 0016146 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python
# -*- encoding: utf-8 -*-
from urllib.parse import ParseResult
from urllib.parse import quote
from urllib.parse import SplitResult
from urllib.parse import unquote
from urllib.parse import urlparse
from urllib.parse import urlunparse
from caldav.lib.python_utilities import to_normal_str
from caldav.lib.python_utilities import to_unicode
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 a URL object, a string or a 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 a 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 = path.path
else:
sep = "/"
if self.path.endswith("/"):
sep = ""
ret_path = "%s%s%s" % (self.path, sep, 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-1.3.9/caldav/lib/vcal.py 0000664 0000000 0000000 00000017252 14536110754 0016272 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python
# -*- encoding: utf-8 -*-
import datetime
import logging
import re
import uuid
import icalendar
from caldav.lib.python_utilities import to_normal_str
## Global counter. We don't want to be too verbose on the users, ref https://github.com/home-assistant/core/issues/86938
fixup_error_loggings = 0
## Fixups to the icalendar data to work around compatibility 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.
5) Zimbra can apparently create events with both dtstart, dtend
and duration set - which is forbidden according to the RFC. We
should probably verify that the data is consistent. As for now,
we'll just drop DURATION or DTEND (whatever comes last).
"""
event = to_normal_str(event)
if not event.endswith("\n"):
event = event + "\n"
## TODO: add ^ before COMPLETED and CREATED?
## 1) Add an arbitrary time if completed is given as date
fixed = re.sub(r"COMPLETED:(\d+)\s", r"COMPLETED:\g<1>T120000Z", 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 ... and ...
## 5 prepare to remove DURATION or DTEND/DUE if both DURATION and
## DTEND/DUE is set.
## remove duplication of DTSTAMP
fixed2 = (
"\n".join(filter(LineFilterDiscardingDuplicates(), fixed.strip().split("\n")))
+ "\n"
)
if fixed2 != event:
global fixup_error_loggings
fixup_error_loggings += 1
remove_bit = lambda n: n & (n - 1)
if not remove_bit(fixup_error_loggings):
log = logging.error
else:
log = logging.debug
log(
"""Ical data was modified to avoid compatibility issues
(Your calendar server breaks the icalendar standard)
This is probably harmless, particularly if not editing events or tasks
(error count: %i - this error is ratelimited)"""
% fixup_error_loggings,
exc_info=True,
)
try:
import difflib
log(
"\n".join(
difflib.unified_diff(
event.split("\n"), fixed2.split("\n"), lineterm=""
)
)
)
except:
log("Original: \n" + event)
log("Modified: \n" + fixed2)
return fixed2
class LineFilterDiscardingDuplicates:
"""Needs to be a class because it keeps track of whether a certain
group of date line was already encountered within a vobject.
This must be called line by line in order on the complete text, at
least comprising the complete vobject.
"""
def __init__(self):
self.stamped = 0
self.ended = 0
def __call__(self, line):
if line.startswith("BEGIN:V"):
self.stamped = 0
self.ended = 0
elif re.match("(DURATION|DTEND|DUE)[:;]", line):
if self.ended:
return False
self.ended += 1
elif re.match("DTSTAMP[:;]", line):
if self.stamped:
return False
self.stamped += 1
return True
## 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(tz=datetime.timezone.utc))
if not props.get("uid") and not "\nUID:" in (ical_fragment or ""):
component.add("uid", uuid.uuid1())
my_instance.add_component(component)
## STATUS should default to NEEDS-ACTION for tasks, if it's not set
## (otherwise we cannot easily add a task to a davical calendar and
## then find it again - ref https://gitlab.com/davical-project/davical/-/issues/281
if (
not props.get("status")
and not "\nSTATUS:" in (ical_fragment or "")
and objtype == "VTODO"
):
props["status"] = "NEEDS-ACTION"
else:
if not ical_fragment.strip().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 isinstance(props[prop], datetime.datetime) and not props[prop].tzinfo:
## We need to have a timezone! Assume UTC.
props[prop] = props[prop].astimezone(datetime.timezone.utc)
if prop in ("child", "parent"):
for value in props[prop]:
component.add(
"related-to",
str(value),
parameters={"reltype": prop.upper()},
encode=True,
)
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,
count=1,
)
return ret
caldav-1.3.9/caldav/objects.py 0000664 0000000 0000000 00000327742 14536110754 0016240 0 ustar 00root root 0000000 0000000 #!/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 collections import defaultdict
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 ClassVar, 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.
TODO: This is old code, it's querying for DisplayName and
ResourceTypes prop and returning a tuple of those. Those two
are relatively arbitrary. I think it's mostly only calendars
having DisplayName, but it may make sense to ask for the
children of a calendar also as an alternative way to get all
events? It should be redone into a more generic method, and
it should probably return a dict rather than a tuple. We
should also look over to see if there is any code duplication.
"""
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:
## COMPATIBILITY HACK - see https://github.com/python-caldav/caldav/issues/309
body = to_wire(body)
if (
ret.status == 500
and not b"getetag" in body
and b"" in body
):
body = body.replace(
b"", b""
)
return self._query(
body, depth, query_method, url, expected_return_value
)
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 "//" in path and path.replace("//", "/") in properties:
## ref https://github.com/python-caldav/caldav/issues/302
## though, it would be nice to find the root cause,
## self.url should not contain double slashes in the first place
rc = properties[path.replace("//", "/")]
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):
try:
return (
str(self.get_property(dav.DisplayName(), use_cached=True)) or self.url
)
except:
return str(self.url)
def __repr__(self):
return "%s(%s)" % (self.__class__.__name__, self.url)
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:
try:
cal_id = c_url.split("/")[-2]
except:
log.error(f"Calendar {c_name} has unexpected url {c_url}")
cal_id = None
cals.append(
Calendar(self.client, id=cal_id, url=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() object
* 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, cal_url=None):
"""
The calendar method will return a calendar object.
It will not initiate any communication with the server.
"""
if not cal_url:
return self.calendar_home_set.calendar(name, cal_id)
else:
return Calendar(self.client, url=self.client.url.join(cal_url))
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
## implementations return 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)
):
## TODO: the ical_fragment code is not much tested
if ical and not "ical_fragment" in ical_data:
ical_data["ical_fragment"] = ical
return vcal.create_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
## TODO: should be deprecated
## TODO: think more through this - is `save_foo` better than `add_foo`?
## `save_foo` should not be used for updating existing content on the
## calendar!
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"
):
"""
WARNING: DEPRECATED
"""
## 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 occurring 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 release 2.0 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,
props=None,
**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
* no-category, no-summary, etc ... search for objects that does not
have those attributes. TODO: WRITE TEST CODE!
* 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, props=props, **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, props=props
)
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:
## This would not be needed if the servers would follow the standard ...
o.load(only_if_unloaded=True)
## Google sometimes returns empty objects
objects = [o for o in objects if o.icalendar_component]
for o in objects:
component = o.icalendar_component
if component is None:
continue
recurrence_properties = ["exdate", "exrule", "rdate", "rrule"]
if any(key in component for key in recurrence_properties):
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 = []
comp = x.icalendar_component
defaults = {
## TODO: all possible non-string sort attributes needs to be listed here, otherwise we will get type errors when comparing objects with the property defined vs undefined (or maybe we should make an "undefined" object that always will compare below any other type? Perhaps there exists such an object already?)
"due": "2050-01-01",
"dtstart": "1970-01-01",
"priority": 0,
"status": {
"VTODO": "NEEDS-ACTION",
"VJOURNAL": "FINAL",
"VEVENT": "TENTATIVE",
}[comp.name],
"category": "",
## Usage of strftime is a simple way to ensure there won't be
## problems if comparing dates with timestamps
"isnt_overdue": not (
"due" in comp
and comp["due"].dt.strftime("%F%H%M%S")
< datetime.now().strftime("%F%H%M%S")
),
"hasnt_started": (
"dtstart" in comp
and comp["dtstart"].dt.strftime("%F%H%M%S")
> datetime.now().strftime("%F%H%M%S")
),
}
for sort_key in sort_keys:
val = comp.get(sort_key, None)
if val is None:
ret.append(defaults.get(sort_key.lower(), ""))
continue
if hasattr(val, "dt"):
val = val.dt
elif hasattr(val, "cats"):
val = ",".join(val.cats)
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)
## partial workaround for https://github.com/python-caldav/caldav/issues/201
for obj in objects:
try:
obj.load(only_if_unloaded=True)
except:
pass
return objects
def build_search_xml_query(
self,
comp_class=None,
todo=None,
ignore_completed1=None,
ignore_completed2=None,
ignore_completed3=None,
event=None,
filters=None,
expand=None,
start=None,
end=None,
props=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)
if props is None:
props_ = [data]
else:
props_ = [data] + props
prop = dav.Prop() + props_
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
)
for other in kwargs:
find_not_defined = other.startswith("no_")
find_defined = other.startswith("has_")
if find_not_defined:
other = other[3:]
if find_defined:
other = other[4:]
if other in (
"uid",
"summary",
"comment",
"class_",
"class",
"category",
"description",
"location",
"status",
"due",
"dtstamp",
"dtstart",
"dtend",
"duration",
"priority",
):
## category and class_ is special
if other.endswith("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.
## As I read the caldav standard, the latter will be yielded.
target = other.replace("category", "categories")
elif other == "class_":
target = "class"
else:
target = other
if find_not_defined:
match = cdav.NotDefined()
elif find_defined:
raise NotImplemented(
"Seems not to be supported by the CalDAV protocol? or we can negate? not supported yet, in any case"
)
else:
match = cdav.TextMatch(kwargs[other])
filters.append(cdav.PropFilter(target.upper()) + match)
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
if item.icalendar_component:
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 __len__(self):
return len(self.objects)
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.canonical()] = 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:
obj.url = obj.url.canonical()
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
"""
RELTYPE_REVERSER: ClassVar = {
"PARENT": "CHILD",
"CHILD": "PARENT",
"SIBLING": "SIBLING",
}
_ENDPARAM = None
_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, components=["VJOURNAL", "VTODO", "VEVENT"]
).between(start, end)
recurrence_properties = ["exdate", "exrule", "rdate", "rrule"]
# FIXME too much copying
stripped_event = self.copy(keep_uid=True)
# remove all recurrence properties
for component in stripped_event.vobject_instance.components():
if component.name in ("VEVENT", "VTODO"):
for key in recurrence_properties:
try:
del component.contents[key]
except KeyError:
pass
calendar = self.icalendar_instance
calendar.subcomponents = []
for occurrence in recurrings:
occurrence.add("RECURRENCE-ID", occurrence.get("DTSTART"))
calendar.add_component(occurrence)
# add other components (except for the VEVENT itself and VTIMEZONE which is not allowed on occurrence 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()
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:
reltype_reverse = self.RELTYPE_REVERSER[reltype]
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
# without str(…), icalendar ignores properties
# because if type(uid) == vText
# then Component._encode does miss adding properties
# see https://github.com/collective/icalendar/issues/557
# workaround should be safe to remove if issue gets fixed
uid = str(uid)
self.icalendar_component.add(
"related-to", uid, parameters={"RELTYPE": reltype}, encode=True
)
self.save()
## TODO: this method is undertested in the caldav library.
## However, as this consolidated and eliminated quite some duplicated code in the
## plann project, it is extensively tested in plann.
def get_relatives(
self, reltypes=None, relfilter=None, fetch_objects=True, ignore_missing=True
):
"""
By default, loads all objects pointed to by the RELATED-TO
property and loads the related objects.
It's possible to filter, either by passing a set or a list of
acceptable relation types in reltypes, or by passing a lambda
function in relfilter.
TODO: Make it possible to also check up reverse relationships
TODO: this is partially overlapped by plann.lib._relships_by_type
in the plann tool. Should consolidate the code.
"""
ret = defaultdict(set)
relations = self.icalendar_component.get("RELATED-TO", [])
if not isinstance(relations, list):
relations = [relations]
for rel in relations:
if relfilter and not relfilter(rel):
continue
reltype = rel.params.get("RELTYPE", "PARENT")
if reltypes and not reltype in reltypes:
continue
ret[reltype].add(str(rel))
if fetch_objects:
for reltype in ret:
uids = ret[reltype]
ret[reltype] = []
for obj in uids:
try:
ret[reltype].append(self.parent.object_by_uid(obj))
except error.NotFoundError:
if not ignore_missing:
raise
return ret
def _get_icalendar_component(self, assert_one=False):
"""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
"""
self.load(only_if_unloaded=True)
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 - should not be used with recurrence sets",
)
def get_due(self):
"""
A VTODO may have due or duration set. Return or calculate due.
WARNING: this method is likely to be deprecated and moved to
the icalendar library. If you decide to use it, please put
caldav<2.0 in the requirements.
"""
i = self.icalendar_component
if "DUE" in i:
return i["DUE"].dt
elif "DTEND" in i:
return i["DTEND"].dt
elif "DURATION" in i and "DTSTART" in i:
return i["DTSTART"].dt + i["DURATION"].dt
else:
return None
get_dtend = get_due
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):
self.load(only_if_unloaded=True)
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, only_if_unloaded=False):
"""
(Re)load the object from the caldav server.
"""
if only_if_unloaded and self.is_loaded():
return
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 a 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 id:
id = str(id)
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 is_loaded(self):
return (
self._data or self._vobject_instance or self._icalendar_instance
) and self.data.count("BEGIN:") > 1
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 a 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",
)
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).
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?)
WARNING: this method is likely to be deprecated and moved to
the icalendar library. If you decide to use it, please put
caldav<2.0 in the requirements.
"""
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 self._ENDPARAM in i:
return i[self._ENDPARAM].dt - i["DTSTART"].dt
elif "DTSTART" in i and not isinstance(i["DTSTART"], datetime):
return timedelta(days=1)
else:
return timedelta(0)
## 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)
"""
_ENDPARAM = "DTEND"
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 a 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.
"""
_ENDPARAM = "DUE"
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*-parameters 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)
## dtstart should be compared to the completion timestamp, which
## is set in UTC in the complete() method. However, dtstart
## may be a naïve or a floating timestamp
## (TODO: what if it's a date?)
## (TODO: we need test code for those corner cases!)
if hasattr(dtstart, "astimezone"):
dtstart = dtstart.astimezone(timezone.utc)
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 interpretation 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(timezone.utc)
if "RRULE" in self.icalendar_component 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()
## TODO: should be moved up to the base class
def set_duration(self, duration, movable_attr="DTSTART"):
"""
If DTSTART and DUE/DTEND 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?
WARNING: this method is likely to be deprecated and moved to
the icalendar library. If you decide to use it, please put
caldav<2.0 in the requirements.
"""
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 set_due(self, due, move_dtstart=False, check_dependent=False):
"""The RFC specifies that a VTODO cannot have both due and
duration, so when setting due, the duration field must be
evicted
check_dependent=True will raise some error if there exists a
parent calendar component (through RELATED-TO), and the parents
due or dtend is before the new dtend).
WARNING: this method is likely to be deprecated and parts of
it moved to the icalendar library. If you decide to use it,
please put caldav<2.0 in the requirements.
WARNING: the check_dependent-logic may be rewritten to support
RFC9253 in 1.x already
"""
if hasattr(due, "tzinfo") and not due.tzinfo:
due = due.astimezone(timezone.utc)
i = self.icalendar_component
if check_dependent:
parents = self.get_relatives({"PARENT"})
for parent in parents["PARENT"]:
pend = parent.get_dtend()
if pend and pend.astimezone(timezone.utc) < due:
if check_dependent == "return":
return parent
raise error.ConsistencyError(
"parent object has due/end %s, cannot procrastinate child object without first procrastinating parent object"
)
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-1.3.9/caldav/requests.py 0000664 0000000 0000000 00000000622 14536110754 0016443 0 ustar 00root root 0000000 0000000 from requests.auth import AuthBase
class HTTPBearerAuth(AuthBase):
def __init__(self, password):
self.password = password
def __eq__(self, other):
return self.password == getattr(other, "password", None)
def __ne__(self, other):
return not self == other
def __call__(self, r):
r.headers["Authorization"] = f"Bearer {self.password}"
return r
caldav-1.3.9/docs/ 0000775 0000000 0000000 00000000000 14536110754 0013714 5 ustar 00root root 0000000 0000000 caldav-1.3.9/docs/Makefile 0000664 0000000 0000000 00000006110 14536110754 0015352 0 ustar 00root root 0000000 0000000 # 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-1.3.9/docs/make.bat 0000664 0000000 0000000 00000006021 14536110754 0015320 0 ustar 00root root 0000000 0000000 @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-1.3.9/docs/source/ 0000775 0000000 0000000 00000000000 14536110754 0015214 5 ustar 00root root 0000000 0000000 caldav-1.3.9/docs/source/caldav/ 0000775 0000000 0000000 00000000000 14536110754 0016446 5 ustar 00root root 0000000 0000000 caldav-1.3.9/docs/source/caldav/davclient.rst 0000664 0000000 0000000 00000000271 14536110754 0021151 0 ustar 00root root 0000000 0000000 :mod:`DAVClient` -- A simple DAV client
=======================================
.. automodule:: caldav.davclient
:synopsis: Class for storing server connection details
:members:
caldav-1.3.9/docs/source/caldav/objects.rst 0000664 0000000 0000000 00000000232 14536110754 0020626 0 ustar 00root root 0000000 0000000 :mod:`objects` -- Object definitions
====================================
.. automodule:: caldav.objects
:synopsis: Base DAVObject class
:members:
caldav-1.3.9/docs/source/conf.py 0000664 0000000 0000000 00000014612 14536110754 0016517 0 ustar 00root root 0000000 0000000 # -*- 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 caldav import __version__ as 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-1.3.9/docs/source/index.rst 0000664 0000000 0000000 00000040714 14536110754 0017063 0 ustar 00root root 0000000 0000000 =================================
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.
Backward compatibility support
==============================
The 1.x version series is supposed to be fully backwards-compatible
with version 0.x, and is intended to be maintained at least
until 2026.
API-changes will be slowly introduced in 1.x. API marked as
deprecated in 0.x or 1.0 will most likely be removed in version 2.0,
API marked with deprecating-warnings in 1.x or 2.0 will most likely be
removed in 3.0. If you have any suggestions on API-changes, please
comment on https://github.com/python-caldav/caldav/issues/92
Notices will be logged when using legacy interface. (See also
https://github.com/python-caldav/caldav/issues/240)
Python compatibility notice
===========================
Most of the code is regularly tested towards different versions of
Python, the oldest being Python 3.7.
Support for Python2 was officially not supported starting from caldav
version 1.0.
Please report issues if you have problems running the caldav library
with old python versions. If it's easy to find a work-around I will
do so (like I did reverting new-style `f"{foo}"` strings into
old-style `"%s" % foo` strings). If it's non-trivial to fix things,
we will officially abandon legacy support.
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.
The library aims to take a pragmatic approach towards compatibility -
it should work as well as possible for as many as possible. This also
means the library will modify icalendar data to get around known
compatibility issues - so no guarantee is given on the immutability of
icalendar data.
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 data
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 1.0 depends
on both, but we're slowly moving towards using icalendar internally.
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 extension 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.search(
start=datetime(2021, 1, 1), end=datetime(2024, 1, 1),event=True, 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.search(
start=datetime(2021, 1, 1), end=datetime(2024, 1, 1),
compfilter='VTODO',event=True, 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-1.3.9/examples/ 0000775 0000000 0000000 00000000000 14536110754 0014602 5 ustar 00root root 0000000 0000000 caldav-1.3.9/examples/basic_usage_examples.py 0000664 0000000 0000000 00000030623 14536110754 0021323 0 ustar 00root root 0000000 0000000 import sys
from datetime import date
from datetime import datetime
from datetime import timedelta
## 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"
headers = {"X-MY-CUSTOMER-HEADER": "123"}
def run_examples():
"""
Run through all the examples, one by one
"""
## We need a client object.
## The client object stores http session information, username, password, etc.
## As of 1.0, Initiating the client object will not cause any server communication,
## so the credentials aren't validated.
## The client object can be used as a context manager, like this:
with caldav.DAVClient(
url=caldav_url,
username=username,
password=password,
headers=headers, # Optional parameter to set HTTP headers on each request if needed
) as 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()
## print out some information
print_calendars_demo(calendars)
## This cleans up from previous runs, if needed:
find_delete_calendar_demo(my_principal, "Test calendar from caldav examples")
## Let's create a new calendar to play with.
## This may raise an error for multiple reasons:
## * server may not support it (it's not mandatory in the CalDAV RFC)
## * principal may not have the permission to create calendars
## * some cloud providers have a global namespace
my_new_calendar = my_principal.make_calendar(
name="Test calendar from caldav examples"
)
## Let's add some events to our newly created calendar
add_stuff_to_calendar_demo(my_new_calendar)
## Let's find the stuff we just added to the calendar
event = search_calendar_demo(my_new_calendar)
## Inspecting and modifying an event
read_modify_event_demo(event)
## Accessing a calendar by a calendar URL
calendar_by_url_demo(client, my_new_calendar.url)
## Clean up - delete things
## (The event would normally be deleted together with the calendar,
## but different calendar servers may behave differently ...)
event.delete()
my_new_calendar.delete()
def calendar_by_url_demo(client, url):
"""Sometimes one may have a calendar URL. Sometimes maybe one would
not want to fetch the principal object from the server (it's not
even required to support it by the caldav protocol).
"""
## No network traffic will be initiated by this:
calendar = client.calendar(url=url)
## At the other hand, this will cause network activity:
events = calendar.events()
## We should still have only one event in the calendar
assert len(events) == 1
event_url = events[0].url
## there is no similar method for fetching an event through
## a URL. One may construct the object like this though:
same_event = caldav.Event(client=client, parent=calendar, url=event_url)
## That was also done without any network traffic. To get the same_event
## populated with data it needs to be loaded:
same_event.load()
assert same_event.data == events[0].data
def read_modify_event_demo(event):
"""This demonstrates how to edit properties in the ical object
and save it back to the calendar. It takes an event -
caldav.Event - as input. This event is found through the
`search_calendar_demo`. The event needs some editing, which will
be done below. Keep in mind that the differences between an
Event, a Todo and a Journal is small, everything that is done to
he event here could as well be done towards a task.
"""
## The objects (events, journals and tasks) comes with some properties that
## can be used for inspecting the data and modifying it.
## event.data is the raw data, as a string, with unix linebreaks
print("here comes some icalendar data:")
print(event.data)
## event.wire_data is the raw data as a byte string with CRLN linebreaks
assert len(event.wire_data) >= len(event.data)
## Two libraries exists to handle icalendar data - vobject and
## icalendar. The caldav library traditionally supported the
## first one, but icalendar is more popular.
## Here is an example
## on how to modify the summary using vobject:
event.vobject_instance.vevent.summary.value = "norwegian national day celebratiuns"
## event.icalendar_instance gives an icalendar instance - which
## normally would be one icalendar calendar object containing one
## subcomponent. Quite often the fourth property,
## icalendar_component is preferable - it gives us the component -
## but be aware that if the server returns a recurring events with
## exceptions, event.icalendar_component will ignore all the
## exceptions.
uid = event.icalendar_component["uid"]
## Let's correct that typo using the icalendar library.
event.icalendar_component["summary"] = event.icalendar_component["summary"].replace(
"celebratiuns", "celebrations"
)
## timestamps (DTSTAMP, DTSTART, DTEND for events, DUE for tasks,
## etc) can be fetched using the icalendar library like this:
dtstart = event.icalendar_component.get("dtstart")
## but, dtstart is not a python datetime - it's a vDatetime from
## the icalendar package. If you want it as a python datetime,
## use the .dt property. (In this case dtstart is set - and it's
## pretty much mandatory for events - but the code here is robust
## enough to handle cases where it's undefined):
dtstart_dt = dtstart and dtstart.dt
## We can modify it:
if dtstart:
event.icalendar_component["dtstart"].dt = dtstart.dt + timedelta(seconds=3600)
## And finally, get the casing correct
event.data = event.data.replace("norwegian", "Norwegian")
## Note that this is not quite thread-safe:
icalendar_component = event.icalendar_component
## accessing the data (and setting it) will "disconnect" the
## icalendar_component from the event
event.data = event.data
## So this will not affect the event anymore:
icalendar_component["summary"] = "do the needful"
assert not "do the needful" in event.data
## The mofifications are still only saved locally in memory -
## let's save it to the server:
event.save()
## NOTE: always use event.save() for updating events and
## calendar.save_event(data) for creating a new event.
## This may break:
# event.save(event.data)
## ref https://github.com/python-caldav/caldav/issues/153
## Finally, let's verify that the correct data was saved
calendar = event.parent
same_event = calendar.event_by_uid(uid)
assert (
same_event.icalendar_component["summary"]
== "Norwegian national day celebrations"
)
def search_calendar_demo(calendar):
"""
some examples on how to fetch objects from the calendar
"""
## It should theoretically be possible to find both the events and
## tasks in one calendar query, but not all server implementations
## supports it, hence either event, todo or journal should be set
## to True when searching. Here is a date search for events, with
## expand:
events_fetched = calendar.search(
start=datetime.now(),
end=datetime(date.today().year + 5, 1, 1),
event=True,
expand=True,
)
## "expand" causes the recurrences to be expanded.
## The yearly event will give us one object for each year
assert len(events_fetched) > 1
print("here is some ical data:")
print(events_fetched[0].data)
## We can also do the same thing without expand, then the "master"
## from 2020 will be fetched
events_fetched = calendar.search(
start=datetime.now(),
end=datetime(date.today().year + 5, 1, 1),
event=True,
expand=False,
)
assert len(events_fetched) == 1
## search can be done by other things, i.e. keyword
tasks_fetched = calendar.search(todo=True, category="outdoor")
assert len(tasks_fetched) == 1
## This those should also work:
all_objects = calendar.objects()
# updated_objects = calendar.objects_by_sync_token(some_sync_token)
# some_object = calendar.object_by_uid(some_uid)
# some_event = calendar.event_by_uid(some_uid)
children = calendar.children()
events = calendar.events()
tasks = calendar.todos()
assert len(events) + len(tasks) == len(all_objects)
assert len(children) == len(all_objects)
## TODO: Some of those should probably be deprecated.
## children is a good candidate.
## Tasks can be completed
tasks[0].complete()
## They will then disappear from the task list
assert not calendar.todos()
## But they are not deleted
assert len(calendar.todos(include_completed=True)) == 1
## Let's delete it completely
tasks[0].delete()
return events_fetched[0]
def print_calendars_demo(calendars):
"""
This example prints the name and URL for every calendar on the list
"""
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: %-36s URL: %s" % (c.name, c.url))
else:
print("your principal has no calendars")
def find_delete_calendar_demo(my_principal, calendar_name):
"""
This example takes a calendar name, finds the calendar if it
exists, and deletes the calendar if it exists.
"""
## Let's try to find or create a calendar ...
try:
## This will raise a NotFoundError if calendar does not exist
demo_calendar = my_principal.calendar(name="Test calendar from caldav examples")
assert demo_calendar
print(
f"We found an existing calendar with name {calendar_name}, now deleting it"
)
demo_calendar.delete()
except caldav.error.NotFoundError:
## Calendar was not found
pass
def add_stuff_to_calendar_demo(calendar):
"""
This demo adds some stuff to the calendar
Unfortunately the arguments that it's possible to pass to save_* is poorly documented.
https://github.com/python-caldav/caldav/issues/253
"""
## Add an event with some certain attributes
may_event = calendar.save_event(
dtstart=datetime(2020, 5, 17, 6),
dtend=datetime(2020, 5, 18, 1),
summary="Do the needful",
rrule={"FREQ": "YEARLY"},
)
## not all calendars supports tasks ... but if it's supported, it should be
## told here:
acceptable_component_types = calendar.get_supported_components()
assert "VTODO" in acceptable_component_types
## Add a task that should contain some ical lines
## Note that this may break on your server:
## * not all servers accepts tasks and events mixed on the same calendar.
## * not all servers accepts tasks at all
dec_task = calendar.save_todo(
ical_fragment="""DTSTART;VALUE=DATE:20201213
DUE;VALUE=DATE:20201220
SUMMARY:Chop down a tree and drag it into the living room
RRULE:FREQ=YEARLY
PRIORITY: 2
CATEGORIES: outdoor"""
)
## ical_fragment parameter -> just some lines
## ical parameter -> full ical object
def _please_ignore_this_hack():
"""
This hack is to be used for the maintainer (or other people
having set up testing servers in tests/private_conf.py) to be able
to verify that this example code works, without editing the
example code itself.
"""
if password == "hunter2":
from tests.conf import client as client_
client = client_()
def _wrapper(*args, **kwargs):
return client
caldav.DAVClient = _wrapper
if __name__ == "__main__":
_please_ignore_this_hack()
run_examples()
caldav-1.3.9/examples/scheduling_examples.py 0000664 0000000 0000000 00000021672 14536110754 0021207 0 ustar 00root root 0000000 0000000 import 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 initial 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-1.3.9/examples/sync_examples.py 0000664 0000000 0000000 00000004530 14536110754 0020030 0 ustar 00root root 0000000 0000000 ## 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)
save_sync_token_to_database(my_updated_events.sync_token)
## 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)
save_sync_token_to_database(my_events.sync_token)
## ... 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-1.3.9/setup.cfg 0000664 0000000 0000000 00000000725 14536110754 0014611 0 ustar 00root root 0000000 0000000 [tox:tox]
envlist = py37,py38,py39,py310,py311,docs,style
[testenv]
deps = --editable .[test]
commands = pytest {posargs:--cov}
[testenv:docs]
deps = sphinx
commands =
sphinx-build -b doctest docs/source docs/build/doctest
[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-1.3.9/setup.py 0000775 0000000 0000000 00000006163 14536110754 0014507 0 ustar 00root root 0000000 0000000 #!/usr/bin/python
# -*- encoding: utf-8 -*-
import ast
import re
import sys
from setuptools import find_packages
from setuptools import setup
## I believe it's good practice to keep the version number
## available as package.__version__
## It is defitively good practice not to have to maintain the
## version number several places.
# However, there seems to be no "best current practice" on how
## to set up version number in the setup.py file?
## I've copied the following from the icalendar library:
_version_re = re.compile(r"__version__\s+=\s+(.*)")
with open("caldav/__init__.py", "rb") as f:
version = str(
ast.literal_eval(_version_re.search(f.read().decode("utf-8")).group(1))
)
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"]
## TODO: consider if automated testing with radicale in addition to
## xandikos would yield any benefits.
test_packages = [
"pytest",
"pytest-coverage",
"coverage",
"sphinx",
]
if sys.version_info.major == 3 and sys.version_info.minor < 9:
test_packages.append("xandikos==0.2.8")
test_packages.append("dulwich==0.20.50")
else:
test_packages.append("xandikos")
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",
"icalendar",
"recurring-ical-events>=2.0.0",
]
+ extra_packages,
extras_require={
"test": test_packages,
},
)
caldav-1.3.9/tests/ 0000775 0000000 0000000 00000000000 14536110754 0014126 5 ustar 00root root 0000000 0000000 caldav-1.3.9/tests/__init__.py 0000664 0000000 0000000 00000000000 14536110754 0016225 0 ustar 00root root 0000000 0000000 caldav-1.3.9/tests/_test_absolute.py 0000664 0000000 0000000 00000002412 14536110754 0017513 0 ustar 00root root 0000000 0000000 # 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-1.3.9/tests/compatibility_issues.py 0000664 0000000 0000000 00000033431 14536110754 0020750 0 ustar 00root root 0000000 0000000 # 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 an 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':
"""A sync-token report depends on the unix timestamp, """
"""several syncs on the same second may cause problems, """
"""so we need to sleep a bit. """
"""(this is a neligible problem if sync returns too much, but may be """
"""disasterously if it returns too little). """,
'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 or vice versa""",
'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""",
'read_only':
"""The calendar server does not support PUT, POST, DELETE, PROPSET, MKCALENDAR, etc""",
'no_relships':
"""The calendar server does not support child/parent relationships between calendar components""",
'isnotdefined_not_working':
"""The is-not-defined in a calendar-query not working as it should - see https://gitlab.com/davical-project/davical/-/issues/281""",
'search_needs_comptype':
"""The server may not always come up with anything useful when searching for objects and omitting to specify weather one wants to see tasks or events""",
}
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",
'no_scheduling',
'text_search_is_case_insensitive',
'text_search_is_exact_match_sometimes',
'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',
'no_relships',
'isnotdefined_not_working',
## 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 = [
#'no_journal', ## it threw a 500 internal server error! ## for old versions
#'nofreebusy', ## for old versions
'fragile_sync_tokens', ## no issue raised yet
'vtodo_datesearch_nodtstart_task_is_skipped_in_closed_date_range', ## no issue raised yet
'isnotdefined_not_working', ## https://gitlab.com/davical-project/davical/-/issues/281
'fastmail_buggy_noexpand_date_search', ## https://gitlab.com/davical-project/davical/-/issues/280
"isnotdefined_not_working",
]
google = [
'no_mkcalendar',
'no_overwrite',
'no_todo',
'no_recurring_expandation'
]
## https://www.sogo.nu/bugs/view.php?id=3065
## left a note about time-based sync tokens on https://www.sogo.nu/bugs/view.php?id=5163
## https://www.sogo.nu/bugs/view.php?id=5282
## https://bugs.sogo.nu/view.php?id=5693
## https://bugs.sogo.nu/view.php?id=5694
sogo = [ ## and in addition ... the requests are efficiently rate limited, as it spawns lots of postgresql connections all until it hits a limit, after that it's 501 errors ...
"time_based_sync_tokens",
"search_needs_comptype",
"fastmail_buggy_noexpand_date_search",
"text_search_not_working",
"isnotdefined_not_working",
'no_journal',
'no_freebusy_rfc4791'
]
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',
'isnotdefined_not_working',
]
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',
'no_relships',
'isnotdefined_not_working',
]
# fmt: on
caldav-1.3.9/tests/conf.py 0000664 0000000 0000000 00000011457 14536110754 0015435 0 ustar 00root root 0000000 0000000 #!/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 .compatibility_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,
}
)
caldav_servers = [x for x in caldav_servers if x.get("enable", 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-1.3.9/tests/conf_private.py.EXAMPLE 0000664 0000000 0000000 00000006067 14536110754 0020262 0 ustar 00root root 0000000 0000000 from 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-1.3.9/tests/proxy.py 0000664 0000000 0000000 00000026220 14536110754 0015663 0 ustar 00root root 0000000 0000000 #!/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 http.server import BaseHTTPRequestHandler
from http.server import HTTPServer
from socketserver import ThreadingMixIn
from time import sleep
from types import CodeType
from types import FrameType
from urllib import parse
from urllib.parse import urlparse
from urllib.parse import urlunparse
from caldav.lib.python_utilities import to_local
from caldav.lib.python_utilities import to_wire
__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 instantiate 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-1.3.9/tests/test_caldav.py 0000664 0000000 0000000 00000305141 14536110754 0016775 0 ustar 00root root 0000000 0000000 #!/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 codecs
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
from datetime import timedelta
from datetime import timezone
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 . 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
from urllib.parse 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",
"19970901T130000Z-123403@example.com",
"20010712T182145Z-123401@example.com",
"20080712T182145Z-123401@example.com",
"takeoutthethrash",
"ctuid1",
"ctuid2",
"ctuid3",
"ctuid4",
"ctuid5",
"ctuid6",
)
## 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
STATUS:NEEDS-ACTION
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
STATUS:NEEDS-ACTION
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 truly 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()
self.cleanup_regime = self.server_params.get("cleanup", "light")
self.calendars_used = []
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()
self._cleanup("pre")
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._cleanup("post")
logging.debug("############## test teardown_method done")
def _cleanup(self, mode=None):
if self.cleanup_regime in ("pre", "post") and self.cleanup_regime != mode:
return
if self.check_compatibility_flag("read_only"):
return ## no cleanup needed
if (
self.check_compatibility_flag("no_mkcalendar")
or self.cleanup_regime == "thorough"
):
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 cal in self.calendars_used:
cal.delete()
if self.check_compatibility_flag("unique_calendar_ids") and mode == "pre":
a = self._teardownCalendar(name="Yep")
if mode == "post":
for calid in (self.testcal_id, self.testcal_id2):
self._teardownCalendar(cal_id=calid)
if self.cleanup_regime == "thorough":
for name in ("Yep", "Yapp", "Yølp", self.testcal_id, self.testcal_id2):
self._teardownCalendar(name=name)
self._teardownCalendar(cal_id=name)
def _teardownCalendar(self, name=None, cal_id=None):
try:
cal = self.principal.calendar(name=name, cal_id=cal_id)
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"
) or self.check_compatibility_flag("read_only"):
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:
if not self.check_compatibility_flag(
"unique_calendar_ids"
) and self.cleanup_regime in ("light", "pre"):
self._teardownCalendar(cal_id=self.testcal_id)
if self.check_compatibility_flag("no_displayname"):
name = None
else:
name = "Yep"
ret = self.principal.make_calendar(
name=name, cal_id=self.testcal_id, **kwargs
)
## TEMP - checking that the calendar works
ret.events()
if self.cleanup_regime == "post":
self.calendars_used.append(ret)
return ret
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 testSearchShouldYieldData(self):
"""
ref https://github.com/python-caldav/caldav/issues/201
"""
c = self._fixCalendar()
if not self.check_compatibility_flag("read_only"):
## populate the calendar with an event or two or three
c.save_event(ev1)
c.save_event(ev2)
c.save_event(ev3)
objects = c.search(event=True)
## This will break if served a read-only calendar without any events
assert objects
## This was observed to be broken for @dreimer1986
assert objects[0].data
def testGetCalendar(self):
# Create calendar
c = self._fixCalendar()
assert c.url is not None
assert len(self.principal.calendars()) != 0
str_ = str(c)
repr_ = repr(c)
## Not sure if those asserts make much sense, the main point here is to exercise
## the __str__ and __repr__ methods on the Calendar object.
if not self.check_compatibility_flag("no_displayname"):
name = c.get_property(dav.DisplayName(), use_cached=True)
if not name:
name = c.url
assert str(name) == str_
assert "Calendar" in repr(c)
assert str(c.url) 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")
self.skip_on_compatibility_flag("read_only")
if not self.check_compatibility_flag(
"unique_calendar_ids"
) and self.cleanup_regime in ("light", "pre"):
self._teardownCalendar(cal_id=self.testcal_id)
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):
self.skip_on_compatibility_flag("read_only")
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")
## may break if we have multiple calendars with the same name
assert c2.url == c.url
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),
uid="ctuid1",
)
events = c.events()
assert len(events) == len(existing_events) + 2
ev2.delete()
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 a 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("read_only")
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")
self.skip_on_compatibility_flag("read_only")
## 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("read_only")
self.skip_on_compatibility_flag("no_mkcalendar")
if not self.check_compatibility_flag(
"unique_calendar_ids"
) and self.cleanup_regime in ("light", "pre"):
self._teardownCalendar(cal_id=self.testcal_id)
self._teardownCalendar(cal_id=self.testcal_id2)
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()
if (
not self.check_compatibility_flag("unique_calendar_ids")
and self.cleanup_regime == "post"
):
self._teardownCalendar(cal_id=self.testcal_id)
self._teardownCalendar(cal_id=self.testcal_id2)
def testCopyEvent(self):
self.skip_on_compatibility_flag("read_only")
self.skip_on_compatibility_flag("no_mkcalendar")
if not self.check_compatibility_flag(
"unique_calendar_ids"
) and self.cleanup_regime in ("light", "pre"):
self._teardownCalendar(cal_id=self.testcal_id)
self._teardownCalendar(cal_id=self.testcal_id2)
## 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
if (
not self.check_compatibility_flag("unique_calendar_ids")
and self.cleanup_regime == "post"
):
self._teardownCalendar(cal_id=self.testcal_id)
self._teardownCalendar(cal_id=self.testcal_id2)
def testCreateCalendarAndEventFromVobject(self):
self.skip_on_compatibility_flag("read_only")
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):
self.skip_on_compatibility_flag("read_only")
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()
if self.check_compatibility_flag("search_needs_comptype"):
assert len(all_events) <= 3
else:
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
## not defined
some_events = c.search(comp_class=Event, no_class=True)
## ev1, ev3 should be returned
## or perhaps not,
## ref https://gitlab.com/davical-project/davical/-/issues/281#note_1265743591
## PUBLIC is default, so maybe no events should be returned?
if not self.check_compatibility_flag("isnotdefined_not_working"):
assert len(some_events) == 2
some_events = c.search(comp_class=Event, no_category=True)
## ev1, ev3 should be returned
if not self.check_compatibility_flag("isnotdefined_not_working"):
assert len(some_events) == 2
some_events = c.search(comp_class=Event, no_dtend=True)
## evr should be returned
if not self.check_compatibility_flag("isnotdefined_not_working"):
assert len(some_events) == 1
self.skip_on_compatibility_flag("text_search_not_working")
## 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"):
assert len(some_events) == 1
some_events = c.search(comp_class=Event, category="personal")
if not self.check_compatibility_flag("category_search_yields_nothing"):
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 OCCASION"
## 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"
)
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_sometimes"):
assert len(some_events) in (0, 1)
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"):
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"):
if self.check_compatibility_flag("fastmail_buggy_noexpand_date_search"):
## fastmail and davical delivers too many recurring events on a date search
## (but fastmail anyway won't get here, as combined search is not working with fastmail)
assert len(no_events) == 1
else:
assert len(no_events) == 1
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"):
assert len(some_events) == 1
some_events = c.search(comp_class=Event, summary="Bastille Day Party")
assert len(some_events) == 1
some_events = c.search(comp_class=Event, summary="Bastille Day")
if self.check_compatibility_flag("text_search_is_exact_match_sometimes"):
assert len(some_events) in (0, 2)
elif self.check_compatibility_flag("text_search_is_exact_match_only"):
assert len(some_events) == 0
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"
## Sorting by upper case should also wor
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 testSearchSortTodo(self):
self.skip_on_compatibility_flag("read_only")
self.skip_on_compatibility_flag("no_todo")
c = self._fixCalendar(supported_calendar_component_set=["VTODO"])
t1 = c.save_todo(
summary="1 task overdue",
due=date(2022, 12, 12),
dtstart=date(2022, 10, 11),
uid="1",
)
t2 = c.save_todo(
summary="2 task future",
due=datetime.now() + timedelta(hours=15),
dtstart=datetime.now() + timedelta(minutes=15),
uid="2",
)
t3 = c.save_todo(
summary="3 task future due",
due=datetime.now() + timedelta(hours=15),
dtstart=datetime(2022, 12, 11, 10, 9, 8),
uid="3",
)
t4 = c.save_todo(summary="4 task priority low", priority=9, uid="4")
t5 = c.save_todo(summary="5 task status completed", status="COMPLETED", uid="5")
t6 = c.save_todo(
summary="6 task has categories", categories="home,garden,sunshine", uid="6"
)
def check_order(tasks, order):
assert [str(x.icalendar_component["uid"]) for x in tasks] == [
str(x) for x in order
]
all_tasks = c.search(todo=True, sort_keys=("uid",))
check_order(all_tasks, (1, 2, 3, 4, 6))
all_tasks = c.search(sort_keys=("summary",))
check_order(all_tasks, (1, 2, 3, 4, 5, 6))
all_tasks = c.search(
sort_keys=("isnt_overdue", "categories", "dtstart", "priority", "status")
)
## This is difficult ...
## * 1 is the only one that is overdue, and False sorts before True, so 1 comes first
## * categories, empty string sorts before a non-empty string, so 6 is at the end of the list
## So we have 2-5 still to worry about ...
## * dtstart - default is "long ago", so 4,5 or 5,4 should be first, followed by 3,2
## * priority - default is 0, so 5 comes before 4
check_order(all_tasks, (1, 5, 4, 3, 2, 6))
def testSearchTodos(self):
self.skip_on_compatibility_flag("read_only")
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()
if self.check_compatibility_flag("search_needs_comptype"):
assert len(all_todos) <= 6
else:
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 tasks
## (Except, if the calendar server does not support is-not-defined very
## well, perhaps only 3 will be returned - see
## https://gitlab.com/davical-project/davical/-/issues/281 )
all_todos = c.search(todo=True)
if self.check_compatibility_flag("isnotdefined_not_working"):
assert len(all_todos) in (3, 6)
else:
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 OCCASION"
## It may not match since the above is to be considered equivalent to the raw data entered.
some_todos = c.search(comp_class=Todo, category="FAMILY,FINANCE")
if not self.check_compatibility_flag("text_search_not_working"):
assert len(some_todos) in (0, 6)
## TODO: We should consider to do client side filtering to ensure exact
## match only on components having MIL as a category (and not FAMILY)
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"):
## This is the correct thing, according to the letter of the RFC
assert len(some_todos) == 6
## completing events, and it should not show up anymore
t3.complete()
t5.complete()
t6.complete()
some_todos = c.search(todo=True)
assert len(some_todos) == 3
## unless we specifically ask for completed tasks
all_todos = c.search(todo=True, include_completed=True)
assert len(all_todos) == 6
def testWrongPassword(self):
if (
not "password" in self.server_params
or not self.server_params["password"]
or self.server_params["password"] == "any-password-seems-to-work"
):
pytest.skip(
"Testing with wrong password skipped as calendar server does not require a password"
)
server_params = self.server_params.copy()
server_params["password"] = (
codecs.encode(server_params["password"], "rot13") + "!"
)
with pytest.raises(error.AuthorizationError):
client(**server_params).principal()
def testCreateChildParent(self):
self.skip_on_compatibility_flag("read_only")
self.skip_on_compatibility_flag("no_relships")
c = self._fixCalendar(supported_calendar_component_set=["VEVENT"])
parent = c.save_event(
dtstart=datetime(2022, 12, 26, 19, 15),
dtend=datetime(2022, 12, 26, 20, 00),
summary="this is a parent event test",
uid="ctuid1",
)
child = c.save_event(
dtstart=datetime(2022, 12, 26, 19, 17),
dtend=datetime(2022, 12, 26, 20, 00),
summary="this is a child event test",
parent=[parent.id],
uid="ctuid2",
)
grandparent = c.save_event(
dtstart=datetime(2022, 12, 26, 19, 00),
dtend=datetime(2022, 12, 26, 20, 00),
summary="this is a grandparent event test",
child=[parent.id],
uid="ctuid3",
)
parent_ = c.event_by_uid(parent.id)
child_ = c.event_by_uid(child.id)
grandparent_ = c.event_by_uid(grandparent.id)
rt = grandparent_.icalendar_component["RELATED-TO"]
if isinstance(rt, list):
assert len(rt) == 1
rt = rt[0]
assert rt == parent.id
assert rt.params["RELTYPE"] == "CHILD"
rt = parent_.icalendar_component["RELATED-TO"]
assert len(rt) == 2
assert set([str(rt[0]), str(rt[1])]) == set([grandparent.id, child.id])
assert set([rt[0].params["RELTYPE"], rt[1].params["RELTYPE"]]) == set(
["CHILD", "PARENT"]
)
rt = child_.icalendar_component["RELATED-TO"]
if isinstance(rt, list):
assert len(rt) == 1
rt = rt[0]
assert rt == parent.id
assert rt.params["RELTYPE"] == "PARENT"
foo = parent_.get_relatives(reltypes={"PARENT"})
assert len(foo) == 1
assert len(foo["PARENT"]) == 1
assert [foo["PARENT"][0].icalendar_component["UID"] == grandparent.id]
foo = parent_.get_relatives(reltypes={"CHILD"})
assert len(foo) == 1
assert len(foo["CHILD"]) == 1
assert [foo["CHILD"][0].icalendar_component["UID"] == child.id]
foo = parent_.get_relatives(reltypes={"CHILD", "PARENT"})
assert len(foo) == 2
assert len(foo["CHILD"]) == 1
assert len(foo["PARENT"]) == 1
foo = parent_.get_relatives(relfilter=lambda x: x.params.get("GAP"))
def testSetDue(self):
self.skip_on_compatibility_flag("read_only")
c = self._fixCalendar(supported_calendar_component_set=["VTODO"])
utc = timezone.utc
some_todo = c.save_todo(
dtstart=datetime(2022, 12, 26, 19, 15, tzinfo=utc),
due=datetime(2022, 12, 26, 20, 00, tzinfo=utc),
summary="Some task",
uid="ctuid1",
)
## setting the due should ... set the due (surprise, surprise)
some_todo.set_due(datetime(2022, 12, 26, 20, 10, tzinfo=utc))
assert some_todo.icalendar_component["DUE"].dt == datetime(
2022, 12, 26, 20, 10, tzinfo=utc
)
assert some_todo.icalendar_component["DTSTART"].dt == datetime(
2022, 12, 26, 19, 15, tzinfo=utc
)
## move_dtstart causes the duration to be unchanged
some_todo.set_due(datetime(2022, 12, 26, 20, 20, tzinfo=utc), move_dtstart=True)
assert some_todo.icalendar_component["DUE"].dt == datetime(
2022, 12, 26, 20, 20, tzinfo=utc
)
assert some_todo.icalendar_component["DTSTART"].dt == datetime(
2022, 12, 26, 19, 25, tzinfo=utc
)
## This task has duration set rather than due. Due should be implied to be 19:30.
some_other_todo = c.save_todo(
dtstart=datetime(2022, 12, 26, 19, 15, tzinfo=utc),
duration=timedelta(minutes=15),
summary="Some other task",
uid="ctuid2",
)
some_other_todo.set_due(
datetime(2022, 12, 26, 19, 45, tzinfo=utc), move_dtstart=True
)
assert some_other_todo.icalendar_component["DUE"].dt == datetime(
2022, 12, 26, 19, 45, tzinfo=utc
)
assert some_other_todo.icalendar_component["DTSTART"].dt == datetime(
2022, 12, 26, 19, 30, tzinfo=utc
)
some_todo.save()
self.skip_on_compatibility_flag("no_relships")
parent = c.save_todo(
dtstart=datetime(2022, 12, 26, 19, 00, tzinfo=utc),
dtend=datetime(2022, 12, 26, 21, 00, tzinfo=utc),
summary="this is a parent test task",
uid="ctuid3",
child=[some_todo.id],
)
## The above updates the some_todo object on the server side, but the local object is not
## updated ... until we reload it
some_todo.load()
## This should work out (set the children due to some time before the parents due)
some_todo.set_due(
datetime(2022, 12, 26, 20, 30, tzinfo=utc),
move_dtstart=True,
check_dependent=True,
)
assert some_todo.icalendar_component["DUE"].dt == datetime(
2022, 12, 26, 20, 30, tzinfo=utc
)
assert some_todo.icalendar_component["DTSTART"].dt == datetime(
2022, 12, 26, 19, 35, tzinfo=utc
)
## This should not work out (set the children due to some time before the parents due)
with pytest.raises(error.ConsistencyError):
some_todo.set_due(
datetime(2022, 12, 26, 21, 30, tzinfo=utc),
move_dtstart=True,
check_dependent=True,
)
child = c.save_todo(
dtstart=datetime(2022, 12, 26, 19, 45),
due=datetime(2022, 12, 26, 19, 55),
summary="this is a test child task",
uid="ctuid4",
parent=[some_todo.id],
)
## This should still work out (set the children due to some time before the parents due)
## (The fact that we now have a child does not affect it anyhow)
some_todo.set_due(
datetime(2022, 12, 26, 20, 31, tzinfo=utc),
move_dtstart=True,
check_dependent=True,
)
assert some_todo.icalendar_component["DUE"].dt == datetime(
2022, 12, 26, 20, 31, tzinfo=utc
)
assert some_todo.icalendar_component["DTSTART"].dt == datetime(
2022, 12, 26, 19, 36, tzinfo=utc
)
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("read_only")
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",
uid="ctuid1",
)
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
"""
self.skip_on_compatibility_flag("read_only")
# 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_component_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, uid="ctuid1"
)
assert len(c.todos()) == 2
# adding a todo without a UID, it should also work (library will add the missing UID)
t7 = 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
t7.delete()
def testTodos(self):
"""
This test will exercise 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
"""
self.skip_on_compatibility_flag("read_only")
# 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)
t4 = c.save_todo(todo4)
todos = c.todos()
assert len(todos) == 3
def uids(lst):
return [x.instance.vtodo.uid for x in lst]
## Default sort order is (due, priority).
assert uids(todos) == uids([t2, t1, t4])
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([t4, t2])
assert pri(todos2) == pri([t4, t2])
todos = c.todos(
sort_keys=(
"summary",
"priority",
)
)
assert uids(todos) == uids([t4, 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
"""
self.skip_on_compatibility_flag("read_only")
# 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()
if self.check_compatibility_flag("isnotdefined_not_working"):
assert len(todos) in (3, 6)
else:
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
assert len([x for x in todos2 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 implementers about the RFC
# breakages.
def testTodoCompletion(self):
"""
Will check that todo-items can be completed and deleted
"""
self.skip_on_compatibility_flag("read_only")
# 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, status="NEEDS-ACTION")
# 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):
self.skip_on_compatibility_flag("read_only")
c = self._fixCalendar(supported_calendar_component_set=["VTODO"])
t6 = c.save_todo(todo6, status="NEEDS-ACTION")
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
c.todos()[0].delete()
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")
todos = c.todos()
assert len(todos) == 2
t8.complete(handle_rrule=True, rrule_mode="safe")
t8.complete(handle_rrule=True, rrule_mode="safe")
assert len(c.todos()) == 1
assert len(c.todos(include_completed=True)) == 5
[x.delete() for x in c.todos(include_completed=True)]
def testTodoRecurringCompleteThisandfuture(self):
self.skip_on_compatibility_flag("read_only")
c = self._fixCalendar(supported_calendar_component_set=["VTODO"])
t6 = c.save_todo(todo6, status="NEEDS-ACTION")
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):
self.skip_on_compatibility_flag("read_only")
# 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")
if not self.check_compatibility_flag(
"unique_calendar_ids"
) and self.cleanup_regime in ("light", "pre"):
self._teardownCalendar(cal_id=self.testcal_id)
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
if (
not self.check_compatibility_flag("unique_calendar_ids")
and self.cleanup_regime == "post"
):
self._teardownCalendar(cal_id=self.testcal_id)
def testUnicodeEvent(self):
self.skip_on_compatibility_flag("read_only")
self.skip_on_compatibility_flag("no_mkcalendar")
if not self.check_compatibility_flag(
"unique_calendar_ids"
) and self.cleanup_regime in ("light", "pre"):
self._teardownCalendar(cal_id=self.testcal_id)
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("read_only")
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?
if not self.check_compatibility_flag(
"unique_calendar_ids"
) and self.cleanup_regime in ("light", "pre"):
self._teardownCalendar(cal_id=self.testcal_id2)
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
"""
self.skip_on_compatibility_flag("read_only")
# 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
"""
self.skip_on_compatibility_flag("read_only")
# 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
"""
self.skip_on_compatibility_flag("read_only")
# 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("read_only")
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 a 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()
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:
# 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": "any-password-seems-to-work",
}
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-1.3.9/tests/test_caldav_unit.py 0000664 0000000 0000000 00000130340 14536110754 0020031 0 ustar 00root root 0000000 0000000 #!/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
from unittest import mock
from urllib.parse import urlparse
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.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
## Some example icalendar data partly 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, davclient=None):
"""
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, davclient)
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
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"19970", b"19980") == 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"
)
def test241(self):
"""
Ref https://github.com/python-caldav/caldav/issues/241
This seems like sort of a duplicate of testThreeTodo, but the ftests actually started failing
"""
assert len(self.todo.data) > 128
self.todo.expand_rrule(
start=datetime(1997, 4, 14, 0, 0), end=datetime(2015, 5, 14, 0, 0)
)
assert len(self.todo.data) > 128
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
response = client.put(
"/foo/møøh/bar".encode("utf-8"),
"bringebærsyltetøy 北京 пиво".encode("utf-8"),
{},
)
assert response.status == 200
assert response.tree is None
@mock.patch("caldav.davclient.requests.Session.request")
def testRequestCustomHeaders(self, mocked):
"""
ref https://github.com/python-caldav/caldav/issues/285
"""
mocked().status_code = 200
mocked().headers = {}
cal_url = "http://me:hunter2@calendar.møøh.example:80/"
client = DAVClient(url=cal_url, headers={"X-NC-CalDAV-Webcal-Caching": "On"})
assert client.headers["Content-Type"] == "text/xml"
assert client.headers["X-NC-CalDAV-Webcal-Caching"] == "On"
@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/"
)
@mock.patch("caldav.CalendarObjectResource.is_loaded")
def testDateSearch(self, mocked):
"""
## ref https://github.com/python-caldav/caldav/issues/133
"""
mocked.__bool__ = lambda self: True
xml = """/principals/calendar/home@petroski.example.com/963/43B060B3-A023-48ED-B9E7-6FFD38D5073E.icsHTTP/1.1 200 OKHTTP/1.1 404 Not Found/principals/calendar/home@petroski.example.com/963/114A4E50-8835-42E1-8185-8A97567B5C1A.icsHTTP/1.1 200 OKHTTP/1.1 404 Not Found/principals/calendar/home@petroski.example.com/963/C20A8820-7156-4DD2-AD1D-17105D923145.icsHTTP/1.1 200 OKHTTP/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), expand=False
)
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 OKHTTP/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 OKUSER_ROOT/dav/tobias%40redpill-linpro.com/Inbox/HTTP/1.1 200 OKInbox/dav/tobias%40redpill-linpro.com/Emailed%20Contacts/HTTP/1.1 200 OKEmailed Contacts/dav/tobias%40redpill-linpro.com/Calendarc5f1a47c-2d92-11e3-b654-0016eab36bf4.icsHTTP/1.1 200 OKCalendarc5f1a47c-2d92-11e3-b654-0016eab36bf4.ics/dav/tobias%40redpill-linpro.com/Yep/HTTP/1.1 200 OKYep
"""
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 OKHTTP/1.1 404 Not Found/17149682/calendars/testcalendar-84439d0b-ce46-4416-b978-7b4009122c64/20010712T182145Z-123401%40example.com.icsBEGIN: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 TestHTTP/1.1 200 OK/17149682/calendars/06888b87-397f-11eb-943b-3af9d3928d42/calfoo3HTTP/1.1 200 OK/17149682/calendars/inbox/HTTP/1.1 200 OKHTTP/1.1 404 Not Found/17149682/calendars/testcalendar-e2910e0a-feab-4b51-b3a8-55828acaa912/YepHTTP/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 OKHwoQEgwAAAh4yw8ntwAAAAAYAhgAIhUIopml463FieB4EKq9+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 testHugeTreeParam(self):
"""
With dealing with a huge XML response, such as event containing attachments, XMLParser will throw an exception
huge_tree parameters allows to handle this kind of events.
"""
xml = """
/17149682/calendars/testcalendar-84439d0b-ce46-4416-b978-7b4009122c64/HTTP/1.1 200 OKHTTP/1.1 404 Not Found/17149682/calendars/testcalendar-84439d0b-ce46-4416-b978-7b4009122c64/20010712T182145Z-123401%40example.com.ics
BEGIN:VCALENDAR
PRODID:-//MDaemon Technologies Ltd//MDaemon 21.5.2
VERSION:2.0
METHOD:PUBLISH
BEGIN:VEVENT
UID:
040000008200E0007000B7101A82E0080000000050BF99B19D31
SEQUENCE:0
DTSTAMP:20230213T142930Z
SUMMARY:This a summary of a very bug event
DESCRIPTION:Description of this very big event
LOCATION:Somewhere
ORGANIZER:MAILTO:noreply@test.com
PRIORITY:5
ATTACH;VALUE=BINARY;ENCODING=BASE64;FMTTYPE=image/jpeg;
X-FILENAME=image001.jpg;X-ORACLE-FILENAME=image001.jpg:
"""
xml += (
"gIyIoLTkwKCo2KyIjM4444449QEBAJjBGS0U+Sjk/QD3/2wBDAQsLCw8NDx0QEB09KSMpPT09\n"
* 153490
)
xml += """
/Z
DTSTART;TZID="Europe/Paris":20230310T140000
DTEND;TZID="Europe/Paris":20230310T150000
END:VEVENT
END:VCALENDAR
HTTP/1.1 200 OK
"""
davclient = MockedDAVClient(xml)
resp = mock.MagicMock()
resp.headers = {"Content-Type": "text/xml"}
resp.content = xml
## It seems like the huge_tree flag is not necessary in all
## environments as of 2023-07. Perhaps versioning issues with
## the lxml library.
# davclient.huge_tree = False
# try:
# import pdb; pdb.set_trace()
# DAVResponse(resp, davclient=davclient)
# assert False
# except Exception as e:
# assert type(e) == lxml.etree.XMLSyntaxError
davclient.huge_tree = True
try:
DAVResponse(resp, davclient=davclient)
assert True
except:
assert False
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_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)
def testExtractAuth(self):
"""
ref https://github.com/python-caldav/caldav/issues/289
"""
cal_url = "http://me:hunter2@calendar.example:80/"
with DAVClient(url=cal_url) as client:
assert client.extract_auth_types("Basic\n") == ["basic"]
assert client.extract_auth_types("Basic") == ["basic"]
assert client.extract_auth_types('Basic Realm=foo;charset="UTF-8"') == [
"basic"
]
assert client.extract_auth_types("Basic,dIGEST Realm=foo") == [
"basic",
"digest",
]
caldav-1.3.9/tests/test_cdav.py 0000664 0000000 0000000 00000005157 14536110754 0016464 0 ustar 00root root 0000000 0000000 import 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("