From e4b9100147384f13381eb87a0624014bf79f4f7e Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Thu, 8 Oct 2026 11:28:26 +0530 Subject: [PATCH 1/3] docs: how-to coverage, pycal theming, RFC citation audit --- .github/workflows/tests.yml | 40 +++ .gitignore | 12 +- .readthedocs.yml | 9 +- .vale.ini | 11 + LICENSES/CC-BY-SA-4.0.txt | 170 +++++++++++ README.md | 10 +- REUSE.toml | 16 +- .../calendaring-jmap/FrustrationWords.yml | 15 + docs/.vale/styles/calendaring-jmap/Terms.yml | 15 + .../config/vocabularies/Base/accept.txt | 41 +++ docs/_static/css/pycal-theme.css | 283 ++++++++++++++++++ docs/_static/img/pycal-icon.png | Bin 0 -> 66184 bytes docs/_static/js/sync-bootstrap-theme.js | 28 ++ CHANGES.rst => docs/changelog.rst | 5 + docs/conf.py | 48 ++- docs/contribute.rst | 72 ++++- docs/explanation/design.rst | 45 ++- docs/explanation/index.rst | 16 + docs/how-to/attachments.rst | 48 +++ docs/how-to/authenticate.rst | 36 ++- docs/how-to/calendars.rst | 87 +++++- docs/how-to/contacts.rst | 32 ++ docs/how-to/convert.rst | 23 ++ docs/how-to/errors.rst | 4 +- docs/how-to/events.rst | 26 +- docs/how-to/example-sync-cache.rst | 96 ++++++ docs/how-to/free-busy.rst | 36 +++ docs/how-to/index.rst | 30 ++ docs/how-to/install.rst | 10 + docs/how-to/logging.rst | 34 +++ docs/how-to/push-notifications.rst | 51 ++++ docs/how-to/scheduling.rst | 42 +++ docs/how-to/sync.rst | 4 +- docs/how-to/tasks.rst | 30 +- docs/index.rst | 46 +-- docs/reference/async_client.rst | 3 + docs/reference/changelog.rst | 4 - docs/reference/client.rst | 3 + docs/reference/constants.rst | 11 + docs/reference/convert.rst | 20 ++ docs/reference/index.rst | 22 +- docs/reference/objects.rst | 5 + docs/reference/server-compatibility.rst | 47 +++ docs/reference/session.rst | 11 + docs/release.rst | 62 ++++ docs/security.rst | 9 +- docs/tutorials/quickstart.rst | 2 + ...+docs-attachments-how-to.documentation.rst | 1 + news/+docs-autodoc-coverage.documentation.rst | 1 + ...-broken-cross-references.documentation.rst | 1 + ...lendar-management-how-to.documentation.rst | 1 + ...ndar-share-with-readback.documentation.rst | 1 + news/+docs-close-method.documentation.rst | 1 + ...cs-config-precedence-fix.documentation.rst | 1 + news/+docs-contacts-how-to.documentation.rst | 1 + ...ersion-layer-explanation.documentation.rst | 1 + news/+docs-convert-how-to.documentation.rst | 1 + ...cs-design-sections-added.documentation.rst | 1 + ...or-hierarchy-explanation.documentation.rst | 1 + ...s-footer-version-removed.documentation.rst | 1 + news/+docs-free-busy-how-to.documentation.rst | 1 + ...cs-get-connection-params.documentation.rst | 1 + news/+docs-install-extras.documentation.rst | 1 + ...k-contribute-and-release.documentation.rst | 1 + news/+docs-logging-how-to.documentation.rst | 1 + ...ush-notifications-how-to.documentation.rst | 1 + ...+docs-push-security-note.documentation.rst | 1 + news/+docs-py-typed-note.documentation.rst | 1 + ...+docs-pycal-theme-colors.documentation.rst | 1 + ...-read-participant-status.documentation.rst | 1 + news/+docs-release-process.documentation.rst | 1 + .../+docs-scheduling-how-to.documentation.rst | 1 + ...ocs-search-events-how-to.documentation.rst | 1 + news/+docs-seo-meta-tags.documentation.rst | 1 + ...ver-compatibility-matrix.documentation.rst | 1 + ...+docs-sync-cache-example.documentation.rst | 1 + news/+docs-task-sync-how-to.documentation.rst | 1 + ...fix-docker-readme-claims.documentation.rst | 1 + ...docker-readme-data-claim.documentation.rst | 1 + ...+fix-get-jmap-client-auth-kwarg.bugfix.rst | 1 + news/+fix-stale-docs.documentation.2.rst | 1 - ...ix-title-underline-claim.documentation.rst | 1 + ...adme-accurate-specs-list.documentation.rst | 1 + news/+remove-caldav-framing.documentation.rst | 1 + ...+remove-nonexistent-async-extra.bugfix.rst | 1 + pyproject.toml | 8 +- src/calendaring_jmap/__init__.py | 6 +- src/calendaring_jmap/_config.py | 6 +- src/calendaring_jmap/_methods/__init__.py | 4 +- src/calendaring_jmap/_methods/calendar.py | 8 +- src/calendaring_jmap/_methods/contact.py | 20 +- src/calendaring_jmap/_methods/event.py | 12 +- src/calendaring_jmap/_methods/principal.py | 26 +- src/calendaring_jmap/_methods/push.py | 10 +- src/calendaring_jmap/_methods/task.py | 18 +- src/calendaring_jmap/async_client.py | 46 +-- src/calendaring_jmap/client.py | 233 +++++++------- src/calendaring_jmap/constants.py | 8 +- src/calendaring_jmap/convert/__init__.py | 2 +- src/calendaring_jmap/convert/_fixup.py | 18 +- src/calendaring_jmap/convert/_patch.py | 2 +- src/calendaring_jmap/convert/_utils.py | 4 +- src/calendaring_jmap/convert/ical_to_jscal.py | 44 +-- src/calendaring_jmap/convert/jscal_to_ical.py | 57 ++-- src/calendaring_jmap/error.py | 6 +- src/calendaring_jmap/objects/busy_interval.py | 11 +- src/calendaring_jmap/objects/calendar.py | 9 +- .../objects/calendar_object.py | 12 +- src/calendaring_jmap/objects/contact.py | 13 +- .../tests/test_jmap_integration.py | 71 +++-- src/calendaring_jmap/tests/test_jmap_unit.py | 47 +-- tests/docker/cyrus/README.md | 4 +- tests/docker/stalwart/README.md | 6 +- 113 files changed, 1996 insertions(+), 423 deletions(-) create mode 100644 .vale.ini create mode 100644 LICENSES/CC-BY-SA-4.0.txt create mode 100644 docs/.vale/styles/calendaring-jmap/FrustrationWords.yml create mode 100644 docs/.vale/styles/calendaring-jmap/Terms.yml create mode 100644 docs/.vale/styles/config/vocabularies/Base/accept.txt create mode 100644 docs/_static/css/pycal-theme.css create mode 100644 docs/_static/img/pycal-icon.png create mode 100644 docs/_static/js/sync-bootstrap-theme.js rename CHANGES.rst => docs/changelog.rst (88%) create mode 100644 docs/explanation/index.rst create mode 100644 docs/how-to/attachments.rst create mode 100644 docs/how-to/contacts.rst create mode 100644 docs/how-to/convert.rst create mode 100644 docs/how-to/example-sync-cache.rst create mode 100644 docs/how-to/free-busy.rst create mode 100644 docs/how-to/index.rst create mode 100644 docs/how-to/logging.rst create mode 100644 docs/how-to/push-notifications.rst create mode 100644 docs/how-to/scheduling.rst delete mode 100644 docs/reference/changelog.rst create mode 100644 docs/reference/constants.rst create mode 100644 docs/reference/convert.rst create mode 100644 docs/reference/server-compatibility.rst create mode 100644 docs/reference/session.rst create mode 100644 docs/release.rst create mode 100644 news/+docs-attachments-how-to.documentation.rst create mode 100644 news/+docs-autodoc-coverage.documentation.rst create mode 100644 news/+docs-broken-cross-references.documentation.rst create mode 100644 news/+docs-calendar-management-how-to.documentation.rst create mode 100644 news/+docs-calendar-share-with-readback.documentation.rst create mode 100644 news/+docs-close-method.documentation.rst create mode 100644 news/+docs-config-precedence-fix.documentation.rst create mode 100644 news/+docs-contacts-how-to.documentation.rst create mode 100644 news/+docs-conversion-layer-explanation.documentation.rst create mode 100644 news/+docs-convert-how-to.documentation.rst create mode 100644 news/+docs-design-sections-added.documentation.rst create mode 100644 news/+docs-error-hierarchy-explanation.documentation.rst create mode 100644 news/+docs-footer-version-removed.documentation.rst create mode 100644 news/+docs-free-busy-how-to.documentation.rst create mode 100644 news/+docs-get-connection-params.documentation.rst create mode 100644 news/+docs-install-extras.documentation.rst create mode 100644 news/+docs-link-contribute-and-release.documentation.rst create mode 100644 news/+docs-logging-how-to.documentation.rst create mode 100644 news/+docs-push-notifications-how-to.documentation.rst create mode 100644 news/+docs-push-security-note.documentation.rst create mode 100644 news/+docs-py-typed-note.documentation.rst create mode 100644 news/+docs-pycal-theme-colors.documentation.rst create mode 100644 news/+docs-read-participant-status.documentation.rst create mode 100644 news/+docs-release-process.documentation.rst create mode 100644 news/+docs-scheduling-how-to.documentation.rst create mode 100644 news/+docs-search-events-how-to.documentation.rst create mode 100644 news/+docs-seo-meta-tags.documentation.rst create mode 100644 news/+docs-server-compatibility-matrix.documentation.rst create mode 100644 news/+docs-sync-cache-example.documentation.rst create mode 100644 news/+docs-task-sync-how-to.documentation.rst create mode 100644 news/+fix-docker-readme-claims.documentation.rst create mode 100644 news/+fix-docker-readme-data-claim.documentation.rst create mode 100644 news/+fix-get-jmap-client-auth-kwarg.bugfix.rst delete mode 100644 news/+fix-stale-docs.documentation.2.rst create mode 100644 news/+fix-title-underline-claim.documentation.rst create mode 100644 news/+readme-accurate-specs-list.documentation.rst create mode 100644 news/+remove-caldav-framing.documentation.rst create mode 100644 news/+remove-nonexistent-async-extra.bugfix.rst diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6283b24..b72fd9e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -65,6 +65,46 @@ jobs: - name: Check code format run: ruff format --check . + vale: + name: Check docs prose with Vale + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Install Vale + run: | + curl -sL https://github.com/errata-ai/vale/releases/download/v3.24.0/vale_3.24.0_Linux_64-bit.tar.gz | tar -xz -C /usr/local/bin vale + + - name: Sync Vale styles + run: vale sync + + - name: Lint docs + run: vale --no-exit docs/ + + docs: + name: Build docs + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.13" + + - name: Install dependencies + run: pip install -e ".[docs]" + + - name: Build docs + run: sphinx-build -W -b html docs docs/_build/html + mypy: name: Type check with mypy runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 9138357..5ba1fd1 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,10 @@ htmlcov/ .nox/ .hypothesis/ +# Linting and type-checking caches +.ruff_cache/ +.mypy_cache/ + # Virtual environments .venv venv/ @@ -61,5 +65,9 @@ src/calendaring_jmap/_version.py # uv lock file uv.lock -# Docs build -docs/_build/ +# Docs build (including comparison builds under other names, e.g. docs/_build2) +docs/_build*/ + +# Vale-synced third-party style packages (fetched by `vale sync`) +docs/.vale/styles/Microsoft/ +docs/.vale/styles/signs-of-ai-writing/ diff --git a/.readthedocs.yml b/.readthedocs.yml index b6a1a22..abea568 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,15 +1,18 @@ # SPDX-FileCopyrightText: 2026 calendaring-jmap contributors # SPDX-License-Identifier: AGPL-3.0-or-later -# Read the Docs configuration file -# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details - version: 2 build: os: ubuntu-24.04 tools: python: "3.12" + jobs: + post_checkout: + # sphinx-last-updated-by-git needs full history; RTD's default clone + # is shallow (depth 50), so a file untouched longer than that would + # otherwise show no last-updated date. + - git fetch --unshallow || true sphinx: configuration: docs/conf.py diff --git a/.vale.ini b/.vale.ini new file mode 100644 index 0000000..5a10293 --- /dev/null +++ b/.vale.ini @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2026 calendaring-jmap contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +StylesPath = docs/.vale/styles +Vocab = Base +MinAlertLevel = suggestion +Packages = Microsoft, https://github.com/ammil-industries/vale-signs-of-ai-writing/releases/download/v0.1.1/signs-of-ai-writing.zip + +[*.rst] +BasedOnStyles = Vale, Microsoft, signs-of-ai-writing, calendaring-jmap +Vale.Terms = NO diff --git a/LICENSES/CC-BY-SA-4.0.txt b/LICENSES/CC-BY-SA-4.0.txt new file mode 100644 index 0000000..835a683 --- /dev/null +++ b/LICENSES/CC-BY-SA-4.0.txt @@ -0,0 +1,170 @@ +Creative Commons Attribution-ShareAlike 4.0 International + + Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses. + +Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. More considerations for licensors. + +Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. + +Although not required by our licenses, you are encouraged to respect those requests where reasonable. More considerations for the public. + +Creative Commons Attribution-ShareAlike 4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions. + +Section 1 – Definitions. + + a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License. + + c. BY-SA Compatible License means a license listed at creativecommons.org/compatiblelicenses, approved by Creative Commons as essentially the equivalent of this Public License. + + d. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. + + e. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements. + + f. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material. + + g. License Elements means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution and ShareAlike. + + h. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License. + + i. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license. + + j. Licensor means the individual(s) or entity(ies) granting rights under this Public License. + + k. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them. + + l. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world. + + m. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning. + +Section 2 – Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to: + + A. reproduce and Share the Licensed Material, in whole or in part; and + + B. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions. + + 3. Term. The term of this Public License is specified in Section 6(a). + + 4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material. + + 5. Downstream recipients. + + A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License. + + B. Additional offer from the Licensor – Adapted Material. Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter’s License You apply. + + C. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material. + + 6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this Public License. + + 3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties. + +Section 3 – License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified form), You must: + + A. retain the following if it is supplied by the Licensor with the Licensed Material: + + i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of warranties; + + v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; + + B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and + + C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information. + + 3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable. + + b. ShareAlike.In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply. + + 1. The Adapter’s License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-SA Compatible License. + + 2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material. + + 3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply. + +Section 4 – Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database; + + b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material, including for purposes of Section 3(b); and + + c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database. +For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights. + +Section 5 – Disclaimer of Warranties and Limitation of Liability. + + a. Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You. + + b. To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You. + + c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability. + +Section 6 – Term and Termination. + + a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or + + 2. upon express reinstatement by the Licensor. + + c. For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. + + d. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. + + e. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. + +Section 7 – Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License. + +Section 8 – Interpretation. + + a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions. + + c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor. + + d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority. + +Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses. + +Creative Commons may be contacted at creativecommons.org. diff --git a/README.md b/README.md index 165dc7a..7a90cc5 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,15 @@ Full documentation: https://calendaring-jmap.readthedocs.io/ - [RFC 8620](https://www.rfc-editor.org/rfc/rfc8620): JMAP core - [draft-ietf-jmap-calendars](https://datatracker.ietf.org/doc/draft-ietf-jmap-calendars/): JMAP Calendars -- [RFC 8984](https://www.rfc-editor.org/rfc/rfc8984): JSCalendar +- [RFC 8984](https://www.rfc-editor.org/rfc/rfc8984): JSCalendar, the conversion target for iCalendar +- [draft-ietf-calext-jscalendar-icalendar](https://datatracker.ietf.org/doc/draft-ietf-calext-jscalendar-icalendar/): iCalendar and JSCalendar conversion rules +- [RFC 9610](https://www.rfc-editor.org/rfc/rfc9610): JMAP Contacts +- [RFC 9670](https://www.rfc-editor.org/rfc/rfc9670): JMAP Sharing and Principals +- [draft-ietf-jmap-tasks](https://datatracker.ietf.org/doc/draft-ietf-jmap-tasks/): JMAP Tasks (expired; see the [server compatibility](https://calendaring-jmap.readthedocs.io/en/stable/reference/server-compatibility.html) page, no known server implements it yet) + +## Changelog + +See the [change log](https://calendaring-jmap.readthedocs.io/en/stable/changelog.html). ## Contributing diff --git a/REUSE.toml b/REUSE.toml index f55ad7a..9b55c1c 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -17,6 +17,12 @@ precedence = "override" SPDX-FileCopyrightText = "2026 calendaring-jmap contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" +[[annotations]] +path = "docs/.vale/styles/config/vocabularies/Base/accept.txt" +precedence = "override" +SPDX-FileCopyrightText = "2026 calendaring-jmap contributors" +SPDX-License-Identifier = "AGPL-3.0-or-later" + # PEP 561 marker file, must be empty [[annotations]] path = "src/calendaring_jmap/py.typed" @@ -25,7 +31,7 @@ SPDX-FileCopyrightText = "2026 calendaring-jmap contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" # Towncrier news fragments. short-lived, deleted once folded into -# CHANGES.rst at release time. Not worth a header each. +# docs/changelog.rst at release time. Not worth a header each. [[annotations]] path = "news/*" precedence = "override" @@ -46,3 +52,11 @@ path = ".github/ISSUE_TEMPLATE/*.md" precedence = "override" SPDX-FileCopyrightText = "2026 calendaring-jmap contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" + +# pycal.org's logo, reused here to show this project is part of the +# Python Calendaring Ecosystem. Binary PNG, no header possible. +[[annotations]] +path = "docs/_static/img/pycal-icon.png" +precedence = "override" +SPDX-FileCopyrightText = "2026 pycal.org" +SPDX-License-Identifier = "CC-BY-SA-4.0" diff --git a/docs/.vale/styles/calendaring-jmap/FrustrationWords.yml b/docs/.vale/styles/calendaring-jmap/FrustrationWords.yml new file mode 100644 index 0000000..4c4eaa1 --- /dev/null +++ b/docs/.vale/styles/calendaring-jmap/FrustrationWords.yml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2026 calendaring-jmap contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +extends: existence +message: "'%s' can read as condescending to a reader who can't complete the task as described; see the contributor guide's prose-style section." +link: https://docs.pylonsproject.org/projects/docs-style-guide/ +ignorecase: true +level: warning +tokens: + - simple + - simply + - just + - very + - easy + - easily diff --git a/docs/.vale/styles/calendaring-jmap/Terms.yml b/docs/.vale/styles/calendaring-jmap/Terms.yml new file mode 100644 index 0000000..ed75e0a --- /dev/null +++ b/docs/.vale/styles/calendaring-jmap/Terms.yml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2026 calendaring-jmap contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +extends: substitution +message: "Prefer '%s' over '%s'." +link: https://docs.pylonsproject.org/projects/docs-style-guide/ +level: warning +ignorecase: false +swap: + Github: GitHub + GITHUB: GitHub + addon: add-on + addons: add-ons + plugin: plug-in + plugins: plug-ins diff --git a/docs/.vale/styles/config/vocabularies/Base/accept.txt b/docs/.vale/styles/config/vocabularies/Base/accept.txt new file mode 100644 index 0000000..4f90022 --- /dev/null +++ b/docs/.vale/styles/config/vocabularies/Base/accept.txt @@ -0,0 +1,41 @@ +AGPL +ASCII +async +autodoc +calendaring-jmap +calendaring_jmap +config +coroutines? +CRUD +Diataxis +dicts +docstrings? +else's +filename +fixup +hardcoding +iCalendar +IETF +inlined +JMAP +JSCalendar +niquests +NNNN +parens +pytest +repo +RFC +RFCs +RST +sharees? +snake_case +subclassing +substrings? +TLS +UID +unobfuscated +[Uu]ntrusted +URN +VCALENDAR +VCONFERENCE +VEVENTs? diff --git a/docs/_static/css/pycal-theme.css b/docs/_static/css/pycal-theme.css new file mode 100644 index 0000000..d3dda7a --- /dev/null +++ b/docs/_static/css/pycal-theme.css @@ -0,0 +1,283 @@ +/* SPDX-FileCopyrightText: 2026 calendaring-jmap contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Ports pycal.org's real design system (CC-BY-SA-4.0, fetched directly from + * https://pycal.org/assets/css/style.css) onto pydata-sphinx-theme's own + * --pst-color- and --pst-font-family- custom properties and matching + * selectors: palette, fonts, the translucent/blurred sticky nav, button and + * link hover states, focus-visible outline, text selection, external-link + * markers, card hover lift/shadow, the back-to-top button, and the custom + * scrollbar. + * + * pycal.org has no dark mode anywhere in its source (no prefers-color-scheme, + * no data-theme, no dark variable block of any kind, confirmed by reading + * its full stylesheet directly), so the dark variant below isn't a port of + * an existing pycal dark mode, since none exists. It's a from-scratch + * extension of pycal's own visual language: the same stone color family + * inverted, the same type scale, radius, shadow style, and transition + * timings, with a slightly lighter accent teal since pycal's literal + * #0f766e is only 3.21:1 against a dark background, below the 4.5:1 WCAG AA + * text threshold, where #0d9488 reaches 4.69:1. + */ + +html[data-theme="light"] { + --pst-color-primary: #0f766e; + --pst-color-secondary: #0b5952; + --pst-color-secondary-bg: #f0fbf9; + --pst-color-accent: #0f766e; + --pst-color-accent-bg: #f0fbf9; + --pst-color-link: #0f766e; + --pst-color-link-hover: #0b5952; + --pst-color-link-higher-contrast: #0b5952; + --pst-color-background: #ffffff; + --pst-color-on-background: #ffffff; + --pst-color-text-base: #111110; + --pst-color-text-muted: #6b6760; + --pst-color-border: #e8e6e0; + --pst-color-border-muted: #e8e6e0; + --pst-color-surface: #fafaf9; + --pst-color-on-surface: #111110; + --pst-color-shadow: rgb(0 0 0 / 0.06); + --pst-color-inline-code: #292724; + --pst-color-table: #111110; + --pst-color-table-inner-border: #e8e6e0; + --pst-color-primary-bg: #f0fbf9; + --pst-color-blockquote-notch: #a8a49a; + --pst-color-target: #fffbeb; + --pst-color-table-row-hover-bg: #f5f4f0; + /* Semantic colors (admonitions): pycal's own badge palette, the closest + real precedent its design system has for status/severity color. */ + --pst-color-info: #1e40af; + --pst-color-info-bg: #eff6ff; + --pst-color-warning: #92400e; + --pst-color-warning-bg: #fffbeb; + --pst-color-success: #065f46; + --pst-color-success-bg: #ecfdf5; + --pst-color-danger: #991b1b; + --pst-color-danger-bg: #fef2f2; + --pcj-header-bg: rgb(255 255 255 / 0.92); +} + +html[data-theme="dark"] { + --pst-color-primary: #0d9488; + --pst-color-secondary: #2dd4bf; + --pst-color-secondary-bg: #102e2a; + --pst-color-accent: #0d9488; + --pst-color-accent-bg: #102e2a; + --pst-color-link: #0d9488; + --pst-color-link-hover: #2dd4bf; + --pst-color-link-higher-contrast: #2dd4bf; + --pst-color-background: #1a1917; + --pst-color-on-background: #1a1917; + --pst-color-text-base: #e8e6e0; + --pst-color-text-muted: #a8a49a; + --pst-color-border: #3a3835; + --pst-color-border-muted: #3a3835; + --pst-color-surface: #232220; + --pst-color-on-surface: #e8e6e0; + --pst-color-shadow: rgb(0 0 0 / 0.3); + --pst-color-inline-code: #d4d1ca; + --pst-color-table: #e8e6e0; + --pst-color-table-inner-border: #3a3835; + --pst-color-primary-bg: #102e2a; + --pst-color-blockquote-notch: #6b6760; + --pst-color-target: #3d3620; + --pst-color-table-row-hover-bg: #2d2b28; + /* Semantic colors (admonitions): same hues as light mode, lightened and + paired with a dark tinted background instead of inverting flat. */ + --pst-color-info: #93c5fd; + --pst-color-info-bg: #1e293b; + --pst-color-warning: #fcd34d; + --pst-color-warning-bg: #3d2e1a; + --pst-color-success: #6ee7b7; + --pst-color-success-bg: #1a2e24; + --pst-color-danger: #fca5a5; + --pst-color-danger-bg: #3a1e1e; + --pcj-header-bg: rgb(26 25 23 / 0.92); +} + +html { + --pst-font-family-base: "Inter", system-ui, -apple-system, sans-serif; + --pst-font-family-heading: "Playfair Display", Georgia, "Times New Roman", serif; + --pst-font-family-monospace: "Cascadia Code", "Fira Code", ui-monospace, monospace; +} + +/* Sticky nav: translucent + blurred, matching pycal's own header exactly. */ +.bd-header { + background-color: var(--pcj-header-bg) !important; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +/* Buttons (search, theme switch, copy, prev/next): pycal's own transition + timing and radius instead of the theme's defaults. */ +.btn, +.copybtn { + transition: all 0.15s; + border-radius: 8px; +} + +.search-button__button:hover, +.theme-switch-button:hover { + background: var(--pst-color-surface); +} + +/* sphinx-copybutton ships its own hardcoded button.copybtn{color:#57606a}. + pydata-sphinx-theme overrides the resting color correctly, with + var(--pst-color-muted), but its hover override reads + var(--pst-color-text), a variable name that doesn't exist anywhere in + this theme (the real ones are --pst-color-text-base/-muted), so the + hover color silently resolves to nothing. Both rules are matched here, + at equal or higher specificity, rather than relying on source order. */ +div.highlight button.copybtn:not(.success) { + color: var(--pst-color-text-muted); + background-color: var(--pst-color-surface); + border-color: var(--pst-color-border); +} + +div.highlight button.copybtn:hover:not(.success) { + color: var(--pst-color-accent); + background-color: var(--pst-color-border); +} + +/* Blanket link-state override: the browser's own built-in :visited purple + otherwise wins over the theme's --pst-color-link on plain, unclassed + links (e.g. the sidebar's "Show Source"/"Edit on GitHub"), since that + link only goes through --pst-color-link indirectly and visited-link + styling is handled specially by browsers. Setting color directly here, + last in the cascade and without relying on inheritance, fixes it. + This also covers every other hover case (a.nav-link, a.reference, the + prev-next-area title), since a:hover matches any anchor. */ +a:link, +a:visited { + color: var(--pst-color-link); +} + +a:hover, +a:visited:hover { + color: var(--pst-color-link-hover); +} + +/* Theme-switch dropdown (System Settings/Light/Dark): the theme's own + dropdown-item color override only targets a.nav-link.dropdown-item (the + navbar's "More" menu), not these button.dropdown-item elements, which + otherwise stay on raw, un-themed Bootstrap colors in both modes. */ +button.dropdown-item { + color: var(--pst-color-text-muted); +} + +button.dropdown-item:hover { + color: var(--pst-color-link-hover); +} + +/* External links: pycal marks them with a small arrow, not just an icon. */ +a.reference.external::after { + content: " \2197"; + font-size: 0.85em; + opacity: 0.7; +} + +/* Card-like surfaces (grid cards, admonitions): pycal's hover lift + shadow. */ +.sd-card, +.admonition { + transition: transform 0.15s, box-shadow 0.15s; + border-radius: 8px; +} + +.sd-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px var(--pst-color-shadow); +} + +/* Focus-visible outline, matching pycal's own 2px solid offset. */ +:focus-visible { + outline: 2px solid var(--pst-color-accent); + outline-offset: 2px; + border-radius: 2px; +} + +/* Text selection, matching pycal's own fixed dark-on-light selection. */ +::selection { + background: var(--pst-color-text-base); + color: var(--pst-color-surface); +} + +/* Scrollbar: Firefox. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--pst-color-border) var(--pst-color-surface); +} + +/* Scrollbar: WebKit/Blink/Chromium, matching pycal's 8px + ring inset. */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--pst-color-surface); +} + +::-webkit-scrollbar-thumb { + background: var(--pst-color-border); + border-radius: 4px; + border: 2px solid var(--pst-color-surface); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--pst-color-text-muted); +} + +/* Back-to-top button, matching pycal's own floating circular button. */ +#pst-back-to-top { + border-radius: 50%; + border: 1px solid var(--pst-color-border); + background: var(--pst-color-surface); + color: var(--pst-color-text-muted); + box-shadow: 0 2px 8px var(--pst-color-shadow); + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +#pst-back-to-top:hover { + background: var(--pst-color-border); + color: var(--pst-color-text-base); +} + +/* Bootstrap tooltips (navbar icon labels): fixed dark-on-light bubble in + both modes, since a tooltip is meant to pop over its surroundings, + matching pycal's own fixed-color ::selection in the same spirit. */ +.tooltip { + --bs-tooltip-bg: var(--pst-color-text-base); + --bs-tooltip-color: var(--pst-color-background); +} + +/* Footer: pycal's own border-top + off-background surface + muted links + that brighten on hover, instead of the theme's plain default bar. */ +.bd-footer { + border-top: 1px solid var(--pst-color-border); + background: var(--pst-color-surface); +} + +.bd-footer .footer-item, +.bd-footer .footer-item p { + color: var(--pst-color-text-muted); + font-size: 0.85rem; +} + +/* Inline prose links (the NLnet acknowledgment sentence), not a nav list: + pycal's own equivalent for "a link inside a sentence" is .prose a, an + underline that darkens on hover, not the undecorated .footer-col ul a + nav-list style, which would otherwise be indistinguishable from the + surrounding plain text here. */ +.bd-footer .footer-item a { + color: var(--pst-color-text-muted); + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-color: var(--pst-color-text-muted); + transition: text-decoration-color 0.15s, color 0.15s; +} + +.bd-footer .footer-item a:hover { + color: var(--pst-color-accent); + text-decoration-color: var(--pst-color-accent); +} diff --git a/docs/_static/img/pycal-icon.png b/docs/_static/img/pycal-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..18e9c368598260e12fe8bff592cee45e9fa1caec GIT binary patch literal 66184 zcmeFYhdb5pA3y$*L`gbjB_q7cC?v^tWJe(s+2fs=z1KMk5m{wrCbJUC-jtO+%HI2A z-PuzuIuYc&gHo8`#B%a$MgBR&ofPRWttPrCm;x-xuv3@1wj-G zq;INY;5Sh@#arOVaVM1rt`H=^MfxW5%7%M@U!HSQ)N|8zv~=?{b9o4PdU^`jIM}&b zm^nQZaCEUsUYBBqpbO9~g_}BFDXSCy71_E*?6|2>E8}aoP72N#h0bK7nWNP$FA3ke zV2e2ut8DiQCKn;d^P=hXUDGp<9QBpV;~w@`uTorHEG^Sr*Zm7ickoz4#)QV&;vL70gVw5z?TM9f$s<7E z|M&I(Eb#vy3j|oGWm7`|%tlVA(%ebL%`BD`^{7~lsJ7fCrc|~V_Eau5`WSWg?9Hz* z7;p0z7q$paw4p*u*^aKK&w8u%9oht;+DWE6_RA*@ut68^>1`cn&0Vye>W)2;JbLiK zMJsP_uVC=_q-I=*nFOU-Rfq?DX6~Mgg2KF5B4^!B1H45VhFTBZ(~873$M#R~E^egt zpw=s6>)ocl?jqI;q~RWp3322NR}w4L#Gu5sH0exxB(Wm5#SfS194V|FCd@V48R}=L z;Nzko^+Q2Eiz7G@#?AA-KPgHr)Z6Xb+q$U}@XlA%3mTe^agJ6#im#Hn5k*>yi^F@?R z>}Lv7b1`RBI%~|0rBKJPQ0>8M>#v`rzoHi6Itr&&N===%x+Z@@h})~;WQHGiXIGrH zy+gr|K&fah!nK8p%SxN5yQ;_=X>hltHOJaL>TGKA0GoF|9-V~j+#A!RSJttgGt=SB zU2ARP-4UT{u+63d6Xnp#S#F)Y(rM+;kyjkS=v^-DuJnjd zf0*-uSd^nQDwZ4ZW6rk@s~J-TnV#U2K2P58=Z*Rw`!AJO(9}x2-K@$of{K%8Vi;Bf z7;$dqDjbVPX%jg1P)(h{cyd^Z%%o&)f0B_DxssC^WoGPP*zS~mdmE`Les*Sm^QrIE zJG*z8q1*DoRu+(09e;x1Zfu{SVWO+gEd@g+7&q&|$Oj5|Tv`$ZoZk9F0@pj-g*U3aYHg!SMK{VqEiW4<4LZBWotV2+U>5w3Ok8lvIaLDuN%h#ng#Y6cT?PYzV0Fo zRI!S%%{QT9uAh+FKL=iMP{6;}u{LUOKmFEmR?6^cW#!pA(|1AOhh2Iyk#7MvBgO2$ zXSOS|n#qnB3*Xqtxe6slp6F?TPH&cU6prkLVriz*v?kBNrdVOQAN=URR!8A+*8F>K zF@bdLy01%(BlFOK*F|+3+?A{(Qj@o8c_R>#iioKM(GLe2>667P4a|Jx0dMm#&rdd- zUK_9UTrZ9ypm$W}QpIQAgnE4&RiXnHU3|jMvG^v20{&7)?=A2Zm8+|AXBYidQBU)# zT3lQxuJNvFIn^>*-mPG(KcT0VV+$L8gI+A~Bu*q!A!Tx+?Mb@&N#3TE>6vRs;i-w0 zr-kKs>P^3KY`XwmPkh*RQHW#z6Y09e$TjQlEihTTafcD_ts8ix!B)ph>6j_iFAHwG zJurFEg1RT(T&}dV^aqEf)loS8X_9zy-nBK>f4XjE(cW)oysfM(B3g(!{@z(v0WtJ; z4trh(Js7_6s)ZVA*gV`X-k!L*(jyVO6_wpayu!(LqgMCK;WnLAjXh899;(TkfqN6v z)pglhF1WKxS(#N!P&qa&x&8$gexGN-b8;s>Tt<40kvxElPx>|RK80-jFx*@8uZHKx_>Sh>wFNc<2Q>+5R< z9edkfd<`m|9`W6A5L-|)*Ppesm73@6puUL>t+=#+->6t)G{({{oFFr0xaW2M z*z0>e{%gJ(BW_c*@E5Lie4K0s1!o#Hte(>0bPha#ck{&e9?+AbrK<}>3$N=|-(AwH z;oNF2^qBVeuG(jBs7kBr!KnZbY2Thnnp^}*4W7ug_KYP;&?ss;mrIz@T!{+7@LVFx zIMVv!-UG4>T;JW`#LND2!TQO`MpkY)A1bloFl++4aeHXB+GmK+-wgGZ{pfaqO6Eo+ zcOAsGg-rJQoU=q@tM7EV0Fh4PbYwxcl|4y6UNYDLTU?Cn93Nk$&LP180ER5$D^5I4 zS3T`Ol#~&&D09eKyVOb?`cbyf?^*Xpwx7F#RH++3?ZrKmLNfKj+-ckuW^a|op5O3t z>TLp7XV<;IpdmRudB9{*3h|}R#IKn@`!d;U>C*HCD6eyoy`FH+bq@QUtKajRHv+wBPS4~ zjMZ`7Qcc&)7+kb8x$16tV`)(^R|W1n_7#aRS{)GKfG}92^L_oZ0E&p%EQ2Z%2hy>>4$)*|Vl|kNm=q`Bh5z zS-8%9JG>!^(#D}c)~=T2?gFLw^Gd9#0uKFU2fi9{2%>_Try+VfAZ7!deI;g;l{ec{ zInt_>5jR{r_zxmm3hcqm1(-+1rBH!!*F@P_zaPGmaOfgVS<@9(ZFdP0y);1J1u*n@ zK(`xrE+f1LaQO2`4o)Lx7*}J@iM9Us07#IAX;i%+UKNs&bx5CqM4Vj=g+s#LmvXo``rfT-{l(co+$~D$lD=I`Jh__&upy}Md zP<_5AFkO{ONK!bfvhr_X-&c35#K0wt%hxG)@wkE>Pm{Ay>3Ncn1d>M*<^w6=weMu> z^!W#GJC9m6=u7Q^SKKT<$1KFU(zuKgPjwN$Mox(OTcPx!<@B*3=%lK;fuQ&5dTc+4 zxL6B#Pxq`M7p;xxV&|H1c+q*+L#0B^)~=+15~RO!7~BBr46WQ+#%p@N!Zy*|sR#Xc z(htgt5mRfApr@J5!fep{B79u^P8&9Y(jhc-Lu|9yz<@`Y3L$aLwK3|nYagg;x7hPz zE3wOYi;IV^y!+bZlaj60CAi+auM}7I^AdGqeb7W=YEf~;5s;LL$(Q>^ z#}5-Kt!Y0LQ>!6C%#Om12V40!`7e@LYKL1%L+aBu2`0sxF>~(3OD={bXr#eUS2rDw z#Y3n7%;B(3leog-qUGOp+V;oLx%wE|PNN!9)JvYD39v2k8YG_Gd-DW>??Th+^UBI!`VP$^v!r%Y%kiHJ zh?A&1h;A;#n9Q=NOsznYJG(+ShrUhseRC48`dBX=jijEuBu3u1{z~s`<3{OX?&5Xg zv#o+E8-sMg6Ac(D;Gc&0j!D9klvys=W?p}3Zyv+YXPwv0EjAx5BNNxV&Gpp7?+lrD z-Jb5-n%k>&E$|}-XH%1zt_7i?2+4yNE5@vvZ-mr9NR| z4`W;FOD%T0rleDKq1S?P-T_Y!Q|P&t-v!Packs>mnUtN4xg4W$_iR2o>*v4XI<~t& z?48_jvwz;^>sdp{Nr)FX8u0yWJNb3nmF%H9DaCzRg%q8tco%)cnNmGr{_}=Ev|J!*IpTpT73>nPzuY;aZDA#ZxBG|! zLdL&JMy2Oi(C^zP-&Nl%SgUrd${W(9Fzq@j0nv+7i{rfk^dSe;R zmmP+{Nxg8&`q3oO`ORg~vikKFMv^Jd0#oWZbYu@X+=@YPbE?0Ze6LeoVLHmB;8lC+vK$ zQF;{i^4iz0kl$5)pB}I6XWhEufUrf$L1H(6q}gRgh%TG0%)KZW>Z2m{TMOr#u-_Ju ze&26CS3**Fy@Lo&_K}iM!UirGd4J(jWjxY=29BT~#l2e@YQw3?|Yi-SGD7ly+|CRsC?n> z1I(UlIlhHGR)8Xa^1II{#FPbM6JPDhhPT8>z(p%60D@TjyO;T!o&JK7kn5s7fQ2Z0 zadqwHt#h18Ht|qH*+O2jrMWZ#8R8%z(_~aPm0`~M^@ZcBvu_UWhLmVV0(P14tEdAC z{Hv&ghU=f!%k=JDljQReW&X2wstNfzB9K%YfNnmg#_XXq3Fc0t( zc=>NU3K!8%DLYrhK#K?$5p?;1^TIv0_eD(jkiR|x(tFmWN>pj%8B5O(VrjC8VUhkR z>A;K;n^2F3_pYBh>$^WraNpSeQV1kG^VyH4Uls?b??chZ+=gcfS#JJyr0_S z7iwpJW^VGLyK~ljE%OB85@@cUq=SOXyY~w8cAv*6?bkwfiNr+m<_Xm{dzGUPw!FB5StOjT6xrKL$Z=0J(m00`b#6iasJm@3-rkz#` zkYrFpo&uvDcmPt8SXt?PsZdx3XUH0XbqEK)X`}3Lh=~s*44Hvo^|~@=e?h zPIlNFLXdt|=*Gn6eljx8jTMrgKY6ORxGTYg_^lF)C7ywq=$4)Nmq9Hr5wrZZxvD2x z50&Np_sm2#SF2vt9u8?0Op^>-cwr~DVr9&dL;+Rf5RwTWZ;^s>x#Zq0ExjBf4_0Q zMQDFW(sMoavJq9oX_5j(YvEDecNA86j9L{e?VKAs2Z{nkO61`syB$YP$9fsiv`0n$&}b*kb^@_4(PyK642t~K zCQel42rn+4NKBRlf;WtYwZnGv)7ewELmVfzhgj*4He6ecrA+)JKi4;w8TXrMKFzV# zJ0|y@mIUj~Nkf+uQ(T-J{OnN@kY%bSn`u~?vCmouSF>ka0Xe66=!&XD-)~q&paBiI zXS;a;pzH?~nBXX4p-WP2i0o=d%2%?SKM&~ZYitM&w~%b&-8@De=7cQVavmYe4?eei>cAT3QawL%4p)Z2?*s<=}7UT;? z!ynr+GTu%P)Q~T^{2Sx<-qY?6&D>Pk%!)YJz`tE5t;xL;z&q@L718M2S|Lbrs`DCk z$d{JQwXTC3TEe&R{@c>1maL!KNeV*pMV&LIlw(^+l zX*X~@FB7}|Zg`*m1=?bT-!}UP^KUDpNJV1P1&xk2Tye2Hx;#ACFN(kPlFtkmX|#tPujk#a2e$SSz)mn>6{VsD%3c*T|&EdrW#`iOB{-vt+A< ziz)r*1&6mjy4TiTXn206ojku!ns^{27#^A%S1`ACMSzLQUifcX^_xU+|BMJ(Klt3; zuLzZ>^UxM4@iuG36_7=K)la+!h*U?n`F<`+FZgk3*T=<07Hsaphoh!DAmayrl@8b@ zuHQZXSu@2Qo4-1gWHP(oL@~s6%UgHS?Iut{|57ZaUU=WI0PK{V^Sx2?u9NYHB@T~X zfa5O@xXq~y-h}L?Uyzx`b=S>TKL3Jk$A{1qE?P>-^Y^5i4Yi$j*tH}IJx`Cp*!=sPdE2u$x2VoeW>B2-365+ zc>_GnD{r28m$ZK;_jg)8cd|>JlyGmHmxqp;T+Mc7GwGA(g@nJpVl|~tLt;gujN$d` z#V}_rl)idrWO`=iXl)s(?3PX>dN6oef-dX5zvSV_V|?}RntRwszNGP;;)&?qYfAR; zcji-sI*X2Zcpdp>^#wOht8n%)?l_4`?*G#d%NGxvUQ=zlCB77{#z@x7nIYA{H?IOW ztU6{(B5=6q3;!@`lGCr4xJKeKVU>OyPy`G(yf3_6H zu6rH2{PPand;5zh%kUmHRIJjC|K;YKowD+_{1brE4>9OBG5F|dQpKb_&IaZyHNZxo z9k)I#nM0&G9Ytn=-!Ptcv7Rs}T0h&N%|mH=X@r-y=*W56M|+R0Fa3W4pzgf^`!K1h zJU1A`PtsF15<{MCmuP>FU*OU=^RANHKL@H9XiX2PRt7J##|IZxxK7ovfOrt7mrAxj zl_M<_r0RiDiYu7%|7vHPejc){!;D)D74gH?V<`%&XV~5KPBh#Gto=66Qsu^FBl7qE z>0?M%fArBZY~aS(WcCP;_yV%m^nk`4&u*($9qK(7PW`)mIV^fCYN|Jc$Zo6f&|ZDq z1xMh8IcLSQS6<)XFHi*-#C5E^*vopOT$P_K2-Nt8fyv2lWlQhuj&Bu?9^{jpBTHIX zxtWm=S75etue##3CBT$_7qu0U^kRcy=r-vOpT#m)SCmvRT?tA1yE**t0Y;r&yq~&m zLuXI7lczL{fafBZwwa!*=S3=}Lrz#rkeVSnnhsg(?eAVKm4#CW7$Czp018e=Tr<7y zqL?KfIt%?@0P?#F;ros}t;LK~MlnOS+_98mk5jC!#O88s3x+202T~*Q0zd-@;J2@6 zAX*!MQ%Zm)+q77j$!13~=PU_BZik15A2OZ)8>0qd1cz&u0=W|kj=Z)6)}BkO-^g-& z7J4Up#nkP^3muU9|wEqxB{F5gxVBz`$5i z(EQZ?(1+xMvyF})EhT1pi#c5#$WA!1kal;0E5r}XzAI@?5;u2}3=i2sph5wgJamub zodk=P8=_=xw}DwsF4gR_S}8RRs16Aoj6N{bgaMkS|7iH%YB;ourj`3{oXZY>7%K7YEd5T2yv+Kr!F%o;5G}yPrI!R{e<&l(Rc)g)&+WPSO;;v;YQ#vQ{}iJtSnM z5Nrmwqon54%#0@>yu;LpVlLtlp520@$c#EE=3^_)HR+79F z$oS*Vf5v!EJ(hum9yByAB`&JBe=G$#Dgyj_g=?uM(~ah;ke%5f`IQVunH)|>N*;~6 zx-w%^1p*d8cE-oFeEi@>lgd22ul_$tT=Z+_T>fV9Y&9q^28b@so2FZJbopuAMZhS3 z7v`A`MA9!#2GvC5TV8Kj2DD zpZ*$U3_rL{)PGb(LWRY}!BQ^>5S3?t=1Mhat*A0WnsL&;N}Iu6_fgj@r%qlGU@L)h zZiL6m#JLhSV5kixUVnRpOr3;&LQ8gdCat$brn=QISEh#R&+p=9w^k1F-oS--JkNuI@_u!S<}GDI47uW@d|J)C`E;R#J4(zbJbdtc?& zJ+_M~_T<96S{uoow~aRTKNhOvze5loW~RJ$j}W?73;sDG*L+R3Xl>S&`*~^wT>Sh| zqxZYcn7&)w!hxGK-T_>|wA9aCC0ujX=_S-QvhxFD=2(-_8R^(P_XPhBZbA@Ly4PkP z2!Kzs-S?REEd53%pP75$l+BS|3u8XV<6+*b@7Nd6mb0T8Bys>th)i{tf=bHXS6Er zp;MTjB@vj1BmgI>^D{+}smE=A5apDCs3>u{U!+Kam0tj1KP6IFRr@jqg7oGG z%MIP^vb;1<5^SGk5+<`MH#1d-pu;0!nRhKD7oxt*nrE+%MBl(|kxbKBf|b`*ocC2r zXuO`{MRrYwWR&TvBniEx%Cr%$UBXu2n`P_-bI?*kmLrEZCV2a+_x-;!c)l;u+j^FL zx9qPKFkX=o?Yd^_ZCg9G!t?A^IRJMc`RJvsLWQOZH&%?G^51*&ErlVb zO3ZOP2ma9MK(Kb!(Ipao#YkB}r^f0}2~|Ry9k`}aG6%?0RY+hoF~C+4K8$p;~DX+YE;=6B6MvsabVwAB-pnwICXyled!8>P@Pi`jl3D zvHXh(|cSB#EA4bkKKISF`01>73O#rTYYapG&bfDswgaCHHG*{Cs^u0Gul3`UAT>6tD9_NWuFGcnAnUw(56 zg6{L-cRvZ5V7^oGD%$>emaYMF95}GU=GZ!~qw~jWymdNNN1*_Y_JS{#P4m>JK{&Gg z0fOvGltHCGP3qT8T2L4urkv{wD@DI7KCeYGI2Fl7nRFqlLr&)W#(BC%TeaX45yX^- z&!&ED#anpU=!h&ozy;;(YBgQidY2}AwjBh4i!L+D#$qwEC?%LV6?d|(c7g0GV!JgjVuKaE2Jx;W$h`N zf${w{5EQ^ljYp1}lH?8Xw;v#Q$a~*^$UW1vTh3s zEUuA65ZeAFf@zFCa`pSHIrAcEQ6~=^{TGZNR!cpM1O!f9%#eZ_OsU?I(xhS4TIhFsH_R}kH)PvA+K2L2*x&oKC$MvnZ29EI&U3exsG1|+?tu3-9ahuO0%b$y zgm?pwt^@&aLvhrM>_{&K(8}CC7@Z$%L{jt*peUH~H5}os^zRxKDXsuD80vg(S#XmK zP~Rd={|w+3I#wPdaIRYy$;5zy>Y2{(h5SO=0SKOIMGVP`A@qX$V;32-<)i53ZU z(*yz68j%#xlnrOx;vYPD2oIZ6JYcSjSh{9@IV01*f0b@ba#s7(Kn;)1YLRR50vl<)eVU4HHFNHZNI4ZNKn6iStbjp9 zz&mGnyS}rw4?L41g;hY^@d`2^g_J^=e6XX*z-+LOuLw7&5|Fi_@6c=LH1L*JXdva# z7%v<;R5#$&G{}!p0P|iv8GaXYDl1fk7)Z+Tk(2{1(-r5TYQ*X!$$XIy9~in_0z+#E z^7;N(nrHw|NmVa!4}zmy4w#{UoKi2E6A&$lPZetNI6C(+l8!fJs7RNR^#!rop#t+v zC@@>jsjVmi5}PU=2O*jQYx7Uts^bhXzP#<|UGb zAU*X4ht;u9_Vo;(lX9k58Kfrpp+q(X1-_(o1|u^F5@Vn~9biLps|8|`wzfNml9AEX z85{@orZ9Y&Ww1MDnsm4-)4As`J@^N4ujRK~DM8%fsMc_GP zKp8MdWEcOV$hV-_G>fn^MUfN$(-~&E!-qUhC&gVfK(;6z2j^~phoO-5i-WJ^B_J7{ zPVy>wwD0EkG&gf`ky0j+Ud|R`CYALZXDmtU+g%}39+j!2lnJGvl4S>5x89clT^{WN zLzYmLN`;FMl&=(S<_u7=ml8yr(6;kI5`*A35`A4A1l0zTJqJpKg$nPuHsXE+Nwku$ z01HY$Y&e~qb7pAY%^PYCCY?|$4{UzexJA42`D%EFhuffmS)ki@euyl^mu3B2IXLr7;=yTP& z$Jr!Oir9cW7s0iFr#4=beXf))xzTRmQBY+H1st2dOK7(z+74}iweNzyb$QM5nuzmG zRl4&GO;cK=)H8tN)+73sAD6a!VZJ9Or$?o%t1pdXk*W+-u@_@Q&bc+FMSYNT`ndsa z$w(m=#n7*KTVHauo^=URR%}ASP?%7^eR5*T(2^LPP*|Jtv9`=iVhSfG?VhS=_T70Y zfuElTm^K=3YO98u{CSfvje((Pek#0V{@@i|%qe)tDiF-G_Qpc@R#dRULd?iZgde==8r2T31UDH1P_TCSFJ;Et zj|YYp^>#nQr)SxT9& zCCeYPd+yK@hFD@4pAM>(hX_*DXzQcLnVR3s?L-gh$(Wjad^1&la<_vWAn>yl{Y#%X zIN8{F*^qzaMNtsBUBZ}p6jL@uDYsG$;OFP|Zf9Y1W44eHO!%QTl(tQ4UA>^TMA3aY zZ|kO=%eyl6baVE+xl1iPX$h_=^w{aMoToN4Bc0YnDO|bJ@2{4Ivvg-q4Po?~Tuc~j zY@q-%SLwFt4^b={5p)_6ZBzRV#6MbKyqr@Xw~cQ}R9m`O;6i<2|A=8tN(n07jP8q|9pkTCB6=ssvU%Y{D6 z)f&#pX)dE!XR$J+7^$Ju^dY9-eskB8=64h?%bY@a%=X??3F2m@e7LCQ;7rYpiGzrj%9Q8>#O z=PW-q@&l4V+~L_K?x4TY6Cc&z+<4_|`xm-LzF#7uQadtrpK~Hjy65=sx}V`G$+D93 zfuKQK(*)=2au0V$)cG_0y0Z^rW+Q||cZQVfn?;@6B1J&caq)U+RW6Bfq}Pc*~cyQ49QOI%4N^)Bh$fXPNT++D9|I;JjoR zA*2oKzgZ~^kM)?iW>O`VNFnE~m$^0dv#xb%vnqVcAKa2fYpV_kvwAQNWX`VLuK^n+ zeVcW+=n@&bWAcm!*hy>joE5H%*y-s|%dxmvsAe^_J<=b1vm*#yq`G8Mx*I$vRc9_0 zxe-RW{^iC6IW-?0KjCTwCAe^9ClLQBx$cO@bC&A4-f@j3e*-uZFWyCBsoOw}k%IqB z`xjO4P*B2=C|oa`i+k$Kmabiu;zPxcdT_72rf&m4a?k37MxC0+0OKbCE!@jcoCccJ zfZ&>idGEaORW!$uu$KWOxg3PP`WQSNmEP_pY+l~-cUDx#o>v{xhJ&Cy+fNRi4C&yH z{OYhz$VRe=6YL*V?Jl9~K5RVS-rBegl=m4i03rhWi!|b`FJuwo;(`QP|F3EU za_##i3m8eL;nPz#tu0yFD>#i9#_yr;f9{2XW~6FY1E67jv73-)QY!l7pttS>E@FQ&nGSIg1cX5F{W&Bo$$3} zI|^=8b%Ez5PtUaB&zJSspXBFH8H%$lUFI~GADW{^sR4CJ-f5t#C@_n7SGjP_w=Ut?lyQn(9m)~b@n$mlu#|ax+!iy<-esd&on^~TZ%2np_bd%`U@*hj!u78 zR?qw7H0rR=w&2PYL3EugclYc@t64_*whL>Q)y}lEcC-2r>z{=(CKNnOia)lsert+x zcR9yO(Lfy(f>`qi&ns~gFDGhIxocVA^a|IpVCc$X!tvDT)3) zIKaj_!5b5vw|pjZ*LjW3XLBWC4J@~(;qf7RH1uQ#U!lvMcQ!`=sBLX ze@105VD4*uWHe5u>v`7Z2`UbLpg+687K|FYMk-x5#+TA1h=MKR#PFZZT2?r(N4yTi zn>{$MD^PFJW20+&;c=QXAB^MUzZr-(8+&Ef32;um*cjiZ-V~ksDQq*uzm-k>B<{3z zf9wZc{MZ1T7`qw5nJfB~kjf`{s6*PwEn4X6>3%}o+eehZk{!2VEIa{gsZVws+e`$Z zph_iT)8bw2h?uOZnJEaxt;wYjj{lQgN8g>egi;KiDe)a(-|Q0dT#ocR*eM1#!d{+f z9|&?aLC%#&a1qVs_nwBx@HO$8*{BHSZK!RUp#QXfcLD{lrZb06PFgP*Si%-S`Q|)R z*HqfZ@Xo9j1vq-)?d0KAKk@9T$k2gH2ckh@*;FXCz70&CS2aCIC{x>UjXHOa0YT!M z+S;FQVOaRQzkvX1g~F^f2(5D^msNMNggbYyG*qv?<)d8a0Gz|0cR!Vlp2((CPu_6* z{e^*`(zjAs>9rG>70&DxH0u~b#Hx~eTOV96)9VfwAojKtI#gk$W9AkHyo0kF?@(~J zkm)gZj#2b8;?;Y?%2Gfv^UBjNcosh>)O3fwgEjnfx_r!+Yim6m^_SMi_k;Wn#+w1z z!?T_`nffyV_I;#mX-MPX2d@c!LS^8@e1b*p!{Y$=-b|rhrDC84q3>rH3KhadhTeTL znr{Zl+1X9oR(D-#t3DO((fHQrnF2(&b9`WSpSowF7D@G@*R_Cl7RV*S8j zJ?eP(Gi_B7tkrStNWNG5Q?xb57aqSc?&FgX*9aymZ(AaV>UM=FZ=_*1L&b7QA4>Sg zGs!{1*Y5?B@R#Synsik58>gzgcA1bekAZYnahW9IPi$10{(~YpIq!E)g!7ZTdx-|b z0~olvP7dtlVJp2ow;?=b~(@^FYk#`2^;&-;84z&3VGrsgG;V{O=uW z<%-R%V1@gC5$)_LJWx=Os&?bI<7(n1_VXXrr^g{aE}? z&PSRS!aQvGY*~%+)f!p43O+Z8P@l*2;ht@=_08m6g=I+9j*=cwI^N!5f8Lg?HlzjT zP(Q9F?{m}N1S2VZdD5X7%pdRPKXH1|yqdFd>!FFBn?bi|^M2s3SYp*V2JV>-BzF~7 zA@D7DlY`xuIb45fiCU=klfT@E6wI0;gCxuVt>HUh8NA=UGhBjnBn(rmR0&W=D)MMJ z-jKqT*m2H|frpU>|1le;A*x3G)uHOHdRS-V-4wSe$y`HvmW#2ujFKKxm>%sg-Gl;* zo#W1`r+>wNG#j*k_hwq_^Wx+>N_xa8Za(e>-9^dry1J=1q(Pjv=lc42&^dsqaO>6o zEIXF3nmFnyT#fIR2pr3pJs0!9X??{(0^&Ma~?qvO%1}z5$i1LxSZr zryP$)YuQGfjdJ(qV!wMYHwZ0206<)kSo7`I8Kl~u=C&uERUt7|xs5v&_9Qb1?Pl4r zvLhp)IDQ5ONVeRq|0KgJFzC+x5OcUNALXjY9<#G((BuF4I-f-%SZco1UF*7{95Trc zyTlU@R4clk#rSPE`;b2!B2s_|v+aEq4dxTMYdITV!(>8QoA>26Iv&KUie5jHx`%t& zs;iX67+Vm?K;^6oJB+Ejn;w^qA?WIIw0k!5@xyh;;USLC;G_Ek{<_VN&a@X>lBoTn z%Hbuaj=w`VZ*TYYK5M`rjYta1yQiN~#Pr&OsPFueD_`n%UP<0?e&nPg@67s~!~S#B ztob7gU(1tCZ-V}Cc%x+gXh7e#|9W9$Qk_Z z%@Omc#^K&eA5PP}v$ptC#o%5r6yGRpwHqb9o95f&-{U*U?ptUjZRaoZVxe-UQ@W>m zLuR9T&=YCJYQL|_R>fUsMQB-qCw$iZGF|g~^y@@xFht>Dh&>s98kr zH4qh=20w0#-V!say#s&8Ohv2x`z`>g?;0FEgXOM6HTS)YPS#8-#3hr#%Q=^#O z%k*B9I&1;Fa79o%@b?(z!8lvvpxv2AhiQNu{4%Z7Sdkbl~O$Ha` zN5WnhhIF+)T9?2DV_!P@xnoYxbHpxP-SC}iUU0(>sqsb`;-eaq^?hyMx%~Fh18t64 zUpDA|@#V`#{3eb9u{1B{A1iKiIleOqkz8;gJoa|jrVSVbDzu$PuF&GoKTI+%CvKeS z7e2$G9<;(y`?`){S28Ym%3+2a>g9#cZy!qz!f=_a{xOtMe*=HMoqKX`huI_)7la=F zvFS&_|tlzXP|m9N_r=I zrCZT42)!!OGq9VWB~v)7dqvKtWhN*5pS+52PV=J}#avc(V;!{{AQqsP7j_+lj^jZ( zW{TFV1V;6^Vs$NYt$DJ_b1hvRD*if=6x==Jv06#pd&@yL@a@2145JuV?-ks$R^##= z-5ho~ZwEIaBV22ru+$YVc{})>dtmVpsi^bIsqsaxCy8C8Dv;Gb| z4vU75&R*^V!*9voAs&*sRAJ;4Y5_Ko>)`pN?+IkMdp&tc#*Ai+z4buhm9Or313AP& z?Aa*)FQfIs{-@iE#~xxY_i?4DtMsUhyTO7mcgHt$NK9tndP*~!LNY?}S-}?ui|#GY zLX2b|_;d@c_gu9M?n8ELWZFD%%(H(xCZ&zmyY~I2=FTNxC^Z+>L98os0JqtCHbvp4 zy_`3j$K6Rr>Ca_BnCDIZ&Ly$CFEl;a?f2;~R-MXyE^cJov2_|80ciClq2V}E7icnd`d9t1y5}4vtOqDLKtUHh7*R%byC8P2BD=)lLRJN zrR8g;X+Geu->aPAG^9mQY?o5;zrBLm+_E}!=G+flKxllUwzW`sb3*u_0IkCaICxDofj)t z8xf;xr;e;VYhsL|0wnd3kM%J=Nl%CNdx#_I7BlrF=5 z!)%`mcHDCi_oPIPN5dQMiTJst#*38jiL4W$>mSoD78f=AXR-Bzu>A5shDu=Rcg7(| zU&6?cU*n)j(9ZD%UdD7$JnC9PPgI)4mA~Ppf(1 z!3}@Y%@^0|r&;X?Pv`%L&AmN`%{S#}-DW1seT3q!ed*Zi3^+n8aBkUPz@3=9r8;7@DzL z)R5Y#`mW4LpMjgsLCLAf&~Qj|IsXflr;}m=71f7sh8u#YZ#524^B$iLLX-P;nfUEf z=UhV?U|3RA|0hjMX)-N8(yFkj+YJ;x>TyrZE$``(BD@I5uny8hm_z%c{$b zT>e@ZcmwZ~GXlFo4ovYQVgvtEH&)A-Rhm+Sl$58rKW|4HYnp@~q_%%{EdNOsQ1ItT za^dcpa7~ZCMsjNVeQkd{ZgYr8-6wxqo(KM}1HH2h3uR!L{huO9T{%Ve`NNC!Q|Hu0ow~a(@wj* zy@C7rUX|D6oHXUR|5*l&&*w+i1#0g6!D>ipzuzoXZ%f!quH*AL`2#4p91}jv%ffB@ zf7>vH$86bawR051{bO3<`DhMuUOe}~NfjPAcy4$3_~f@yZSA`7g8pr06fShzfnKolH<*H4Kp% z!-rPYxIaTn*c8<~{ zqyyc$g1pOld_h^b3whn=s1o=8)TlpBGoft<(s{7%!>et{5rxe<^X`GLcG~%8yMD{N zv08O9KBv5*{|6XYE}9J|x-7O>52U{iT#3%SL)^m^7h<23BDF^^#VRoE%Iu1A&}QId7sJsX`h*)cnNOhB-j1aF*O0yMXS!c* zY;FTd!GXP>V?+o3C_f*niD25&S;QK=$qm?|aI^-*>2J2DXnpJ9Ou4}x1hL5dI`MWg zAu4tMUYjxxJhXLkdj|P77Tu%RL$*SF%6T>St*Z4o#Y4)ac(_b<;LOt`UMrdCXu{D^ zOf~W)B_eYQj-1^rl(CRI0}Ny?uyCFqeJ^ zTL{)0!2450?T@Z&fYZan(R+c!a77s|*#DqrLH>)3OSGgC0l!H}d!?HV>4V#;4`CaB z#ugy4SXr|fUq3fmc(!h{vGK@;=aj9^Qck#&bRzqDNL?^s#SfPGmk=R?do?Cs3c`oK z2DW7y>$eWgSPvB{ep+$Dv1e-{PA8$sy;cSNmetgF>xmUe)w3Zn*S}@$uC*+VJHf91 z;^i;droHWCGE^p0CY6&3M<>crCgMokwz8$ypMS)%H~3i@&+sM#@sHO{`B&csLH`#7 zaA)WY>ZoiWLTb)DjZFS=0Tjz(ltwI0!jPRf+imfY{BEEo0vf1kuHQF*+@k_8r*0xo zHT3Mi4c{9O^q7@Wg{^%(6U6ZDO*D5`tcq?DX zeEh$Ddr(h~x=!z~-si^Sn^r8hA6f9FFRAjBAWu5>37knB8avzWxjx(N3huy*>dnhv zqzT5aR|yBnWpU19lJ3uW#ZR$mwK_+;$o`J zSCuEg?G7ZIp1yrZGwp2g#{STS&ze(J`tEqD$@+(whFk8GoIJbd{HCX&NySwWKo`S+J?eS`;38Qa}J3Z&NRE%JRsD$H@wRvw_IeEdI-k};iSAVJii;T*RB8Xq+g?5I zlXqO_taqxArPQV17s=!wC!4uaPTF>3(2r&(71ySG-ds?r^2IHhZw%1Ub|P6pjWUnd zf0h(%lm{t!KYuHo7ZikV-c;35388dyUD)_*^cdGzV#o%Wu~OE+T35jpir}A+{N9aq zoqbwH9t%1F3oR{^__e3bGgyfYHfbW7C zwAJk}b*s;(r2674svQ?J-^&|JNavQg$>jb0?vk%Dyy!E$JXT`FMhj_X*|)>yC6`xg zbyoUMHnV-+Y=JXNw6Fi(kWbM$D^PKpKZ-a&iF!D3)-b-tABLZvPii5=c%H&ULd|1e zS4oQedjI3qny;H*r;b49rax8Wx2P)Qr`j%l-aKo%61S2xJfCyvz~&{Xai-Y}c61TlC_eIQ!W= zWhqNmP=4^FO7QsjrQFdw5fqh`agBDfkg*)|s?oDK^iY$AETvb^IlezBBd{Wx zmuwCp%-kPyiB(VH`^?H8n^8~Mb*BGFI zxI_`@O7YRpKhvt`8aKTk%4|AGjlWh|-6>|RdZS`!fN~wrt1YEf;uP+$Km7M~?W zdy8WEa3kEM`j^LM=>QiD%hfFB2=CxUT&sf4*@=}$_p1luHho0`(}Sy%fBBh84nJ{A z3$ddxV`g-6H=7!@=U=@q`{D_Ot2MejP2y3Mf%uKdyw5uO7V3GK@_Un?VszFOXzY@A zMvxz+O_C!!6>_^y8HX$(k3sB*Pc(NA(kw&IwYcX1@-L<}UudTMx(}tf_`FY?E9csF zl3rJ{mJ@>V(br9X@Bhg+()jVQQVqS%Mi?AmF)a%7R->=J(}h>DYDskS%ESEkyjqP{ z!y_nJ9yrX3E#vswxXD1;8QJp38a1&p`Q*Xn)BB(XIP|{X9{1fP}1W6!p^XVQ)*@k#>Vfh z(}#X5RqblNekIt1vc1+#Bv&>hf9Wighh{qRj~BdY4bi~e^m8Tt+DP~OORpW0M{@oA zXo{w<;R1onuwAZl=EL8m#|jHR*PG+7Zk&c`pqqeM7&B_Vr|26%ZyRF5Xf~}@SFH1- zO4ei2FW|$u(GE_`iW#Qb?Q1zUDR_0|F?RavX33#o7kD?oDr&_mhtL~LIeScM6a0Vp z@bI$A!EgTPqFGB0SHIL>eG+srau`r6lXjneWsHJlFGZnqciq1Ixgme@K=8-^q?uvz zDoc|>`d#?mZPdiAX1t-$LO~p2Wp32(A`x8nHODrt=eJ+L_dK`eWwhZppW|04vo#!( zUF|8v%`%cSbS2yeubD+yNfFpdH<808^XpVeuLEcEb$I-cdDq()61k6?|IVCtfoARP z@fN34Fq@PfD76{gRLztr4j+A@0gI60IlwWvjJ;;v>{-tOC9pN5?Sp*ZudT+)f7>Pf zOFCwGKt$(E^Gv4iSE;O^*955#yi5miP6xmrgATBp*I@Dqr$gW)eozlf`TUan_K z(9${tN+0+1m4xf925vUmU!b2xi%t<_P|xoE)o-c#uZKiR>iz6*pta1PPv030mS2#T zz4%ohZ`HOQZ75Y8{mcwL4QDZJBb3L&Q(pBx+gP(O@1tgFf~oI?YGp{U(RUEuN6ysj zh0e+RTD$TLEm1@cXcS;{E`!z4kp^fjH>W%v4Udafu z{w^T0kq+L#^TsCWcp3;>(b^j%Gg)Mp>(HsUYy~e#`$8@7{AghhVsTfHY#L4Ho_K{w z1lEHdLg`y8XNOlFKZsu8&6&*1ZXwYUTdFrnQ-}SrvsIg|QTg(lAEwl%+7(km$e)+B zR~}5M`D1@*nTO11-v0$Ls2{?pAqHpomMEFD2<0&DTpNUtp8#ABE-WB*RZu#*2^JMbNPM?m) z*aMI6;zta*#+h0!RI-)dF_LKkO(<>b1w*qR$FQn!ek`8tf z(D`#Eh90`+f#ZQ>hbVPAuJO)ku5{Y|n%^2b6uIALKxY&gP_l=}7%v2;>QFCmpKj3d zYIvwNOl{x*doz&%t&KtQOG6@X)R)U?vNU4{77d-VKs}K3((wr=+E)_uKr4`8oOBy#WhAtx|Jlcc5y}za^{UNq zaA!!V=;ch$C2U6&O)ZJn?)mad4uG)(Q586X*k-x0yXR7kLUOIk>VSEHAL+B@7)f^w}MwER)x+9VNt2^jtRb6&A zC-w1!Nb0k1AsPnfiUS?0g=yQmJxpRe^$z&8^nO~9*R`-~ELQR2f&qS4T{>a0b$5|r z{Ebn%_FZLkmTBB|)IP-}Y*cUZL@8_K?{4-H1)2x_jPCX|Th)3>O~|1aBrbj1?W{t{ zllDLdQdjqK`SKXw0ENi|=MS&Q|9eI5c}!qtj6!YN3sQ+IQ8d}b0G=PJgarz9|BLiP zw{J&~-6wF6Jj+DaLYd{Y9ske8do_!YHawyQaBX>?+AAcglaOz&ht)%?9gwN>4Y5}n z9vXRXK5o5Dg4G1Rk+?N;D&-~-2ZQaWVCsS*$ch+9yCTTJ^4D|8g4}OA2>E~(C#e%u zsvLN1H4pP1t{sAFTk?_SUbrTnYv)Sb4ZPLa;wVZU!!&&F_C`6cso zR6yvJeJrA=v-=aIBaSLd4*j$~iXN*62PlR5lI`5%Ef&yx0I-Bj7_x=i1Vc`8H_-8+fs2v7vX(2Mn@e$YA#q} zt*+<@+6^JWK$Y7BbQprM7S(~h?*ofeFSDZ(kPsbvW#{$H)VC~GUXn^?cTSTcErM0` zR)I8xnNIAZl$B-eoY1ebq7zOQ#3yLqL`U%5Liu=Ug{457H-2-`V#h+3t@NW^%|5aJ zZiiy}UHmVbM+X($T(OXSzzTvBnV=2vAi7%aqi?2QvKTJL8$4cm(G+zcm2L5&iLqpR zqJLGG^{4Jk42+~k*otED-*mk5tdrh~=Abq15KE0b^ z+(ZwZUL?+W?YN}JYGeYQo^jm76m7ZY_uquGw{y`k+}W-HI1-T{pqd;BZk6=xBeyW+ z^^2(zyY^&hexsU&vHRX^Dc;VmiOTCA15Mi_$jo|M-6L{%tX|c};Y5;MEZz-e?O5#4 zd8Pmh!*U(}*R|w2!>*}%yz;t*(NF^HwVm4nRry3+yH0`x?5G_yrglYGevV6_?X$!F z4$wScW1_;T3i8UOWXH@s^x1qCJ*+C^&#skaZ$s>bUKPxWh1r69!)DuOR8f3!8AVDX zYQrvpdf2|t#n+vm>B7*n`_r#6Fj7F17NIWpfCuij!)e${r3kOE&!q;|QO zVO)5xuohptDL7)qeMSVoeCe<0l=FOylpjsXTCk}_|MGH(EA}uw^0U_b zMK*X;a0MqWcp0UKu!K<* z-vymuhFv}I4&s)(f~nqvlZezH?ATE8&4|MWv z&>n}s`d(S_7bWo2E`z18xubxzBW6lSWWFT3mSOg!XqA3ywDl$d> zM7x)9ynh`pTmT;sDg#zb@aJL|yT_`*b#sBf1PlDP7o;!&>)oJr?#I)EJI9mg&xk;p zP7f!<63vGouadp>GrXNwv!SuMPqY%C$vjj|a4CNnahN(Lat%!c`CHntFZHpg{v#Lr z!ihUT-aV(l=Dp)gO=cp6u?L>9BXKzcxki;0n=V4Wo!&~8;-0Rv8>pKmC!YSRdT@P# zPsy!^kk1CUcJ%hYM1k74zTy6vf^fjTPbQM>HzFvy`O>AZO~r{wKXZZ};LLmtl4Be} z6EUDdclYi_5ZdQwCk&^*NAE5PZ)v<0LFRjsJ}S8#Xc>8so$2Yi4P>P_;`p z-97nDY%X@Bi5J+?a>!G&JG3`9W-}n6r~91bx0x(}_=5H8x6&8BF)oWPefsmpyq(nR zVE3L#SF{zQxYTiI@{>Nxy@^Pw&D#x^Wpbmctq0+SQ$lW_F+SW`nk<*6ns|k^_S?VQ zhSc=PUu9KjJFc_8HTwPl#n(vEd>eJ`LIPI;bh@*O0!%Sle8gwP+@39(8kOc7T#lR}c*~w5u+n z%}Q?NbI`zdLp>}V`vn&+6yf(f?>0IJG}VQn z)QRqpY}USavia$V@3bCndSoAL>ox@kl{*}$T7nr>1_{RE^4p~wGXJRsxI$FWocsh` zE7VU9PF!N7Wn>(8*c%&3sS&7l0C6}j72OJAcT&e8(;J{BLcs;*_wu~HZ5ACgIv~NU z2D?FVdH7kbEEK4FsWVI4w-Psh@w6f zQ30uf6IU5&0r2(9sit?Z@l^r^-gkQ}SGp@pGgmm59_^i%j?UQL=R|^CZt%w%#t3r4 zg9L#%$0M{zO_uyziF_OLJ7_z|d>M99LiZ5G%j2to1Ik)j5JDtGnyghfW zNu>*7eI`8VqaZMmLk=E5LWfk%1)yMLI1ghkvlU--Yi50ydHD3$4O308$rsU5x!3TTgZV47ui^S{&%N_P@I{t#W6)n!tA&2g1==%X zVf9UD!_7C*hMfw(yJS3^PHPWN7cAYyCT$2?te}gbClnvAE9(elV1W6U@jQda)Ar9= z`&!4-AadKzV=uc^7Y_Xm_1cs5S1y85V=UbP`@jG*_3}8e3zrrr=p(8k$hBKqZM*b) z@bnXdA!=Vo1hO>Szb+b%(+apI`0YiIXXTrD5=T5%CzdV`P$0wDE-*mR-NPgXU06J2 z++peTy;aA=o*hapz_pMsE!c)kb$U&HieJJ#0hCPqVX}=MkdTfl=(>U+AMki4t=@|5 zw;S&Z(vkr2lxD5xt(a(#(zh{zy)g<&;3tdJCvCsS(Y!39C;`g%(wZ9uT_XV9ze(G9 zB$5R&mclm{;Tn>8<5hF+38{>$+O+1s_lMnL7&qdwbq3pOii+?FI4`c0;}&WbmY>NhZkc>nqbuywRvE_FRl-Ty!Ja zxowzi12Ol}q>cf321(`%f);aM4rXaif+zc|ubk)d)hr;0c*}|+0cC$hkPAzmBvh{= zx(v_6kx<(e;S#U+Ph#F}f`l~As{&wAsm<07Ts~;7}kmHL|g>AH< z1G76wcW}!9sC*IRIwl=&Lu0l#*6V(}eYIF} zsrcd?$p7yM@)#@#okHt4ap9D<4vtk_@xI+1ff4bA3&G{dk88%r}*K z94&Bjs^q`zsRZo&{bzGNr$k z9=WPCeQ_5OD&DVr?e^NQq=ZAU%IkaCq*WhT;15Tn^hZ)k(^19mYXuv0UNMPLMZs7O zZf98Fy_NjNV!w#sdR zYn^gFOow#59RXwIPAE&zwvRAS2(QtCU1xVt&mlC>eW$f0?TD{}*pf4yg9TiFn1Gj2 zGl#9Z49chG^d?KBSs)^#HHu#2uPXuxZrzBf0>VmkM(M zo4mouq~R{=)&giT*N6b$nh93xV5{cgOksfKAJ$3WweT1O+{(eccXejp1{*93a?2tO z9Y+x)<|^tC*MfOaRA2!Ai^jO-ChX!*4AA>f09Y|Zi-XOpnLgWYAfaW9uZ3hiaI5>1 zfAy^YV&@dneWCU2!R;Nf273Up+a8g>J3DvB#wu&AFpfugt$#aW%;t14k`~c*ac{AX ziy1#Ws5bgwkZnfC$loY3j*tKy?WUTk!c9w(X^IG)U-`*+%Om-vF=?V3P+S z!YRxvyHD+JsaT+OK!PtS=-%KjL0oXO^Z*T#sql)ZXrx{V4FP^i0In95<7kkk8Z6l> z)99E~O8e#_;VVCB{yxmGFGQ=R0Ln6foYEXu-k#`9gqYLrsY^XX^lZ{rP&^XLKnnA* z?gIubE@rJGMu8YHZC&QIdY7J)&s@@6?A=g;v~p;X)pl6Ya3X>2hq{+WmS)%sl7m&x zewfpyS1HB=lp`1D1;mr{h4pY#Eo#v*ES!=(VB1=M>)Q&aD&Ju5b@0{0@<51S=5QD$ z$#AJsQ8!)t_KX~y5pah}4XH+~ixe8dwdFG4{!O52<>x@T>s>2zu?w|Y8#d;fEyQt3 z7fP0m*Z+O^7&rY%@DAQFMPlOzu|$$cS%o{@nwy^$AOVt}-Pku?_6xTfZ+F2=7Ah4t zK65RU+?ahL5h9Ia%~Tkmc)>xo{QZl!qzj^gq>D|Z@;;B#E+~e0P`D{BX)SQK1MSr9!U0NXkpgtQ?5h>7T(F;EQ0NKO7Y5Y+>oU31>@&?d zbj@g}gFEYQ4OHGlQ07bjtvT|6$0IRH5L*s!f+sk~fznpBSZOB&X=>hx=TJgtDIa`0 zm0ev%{fpwWKfraGbs65SM!n;ruZLyz-y*c)m9>hl%xYq10JzTC&(KL+eqkUmM9AmT z!?tB(1PzSM&3UXZfLwMI7@i-CjWd9dqtFzR{~Fpk*M32>qKVtkpc+o5N#E5~%fO@Y zcETRRMvv5-kiV)79)5amO|+q)m7+Rl8SDfR%cU21@xinX&<5d7+w;dXhfSJ?KN@(U z2+*mA(I3O_R zcU;nZ;RPw{QtTs%;idsdcr2BdjWef@H^shcdgjC5hqu7{!qPLLE7D_c$?$coiRE?E zvupfTk1g;NurDG^LJ(6q+nECO6#IrX145DO;3^;M$+;r^0Far+jp*6r_gz&F`&J@1 ziqkp+Okl)7;cSsXwHJTILks)}RIeT5=+>9vln4LxzPADp7Pl;ZHl=?F9(B4v4?%jC zE3x)znysax79SwP`4=RW3H2BVrJ+b?L^<}*9v)tLBs8pFr%M64fTi6BkvS{KrBZA~ zDdYpDl9jdiV28d0dzb#6XgLVw%duk@EZu_baxrYLAty64mwu1!FAo%3U24ykxTxhj z-}}Of7MY5U<;pST2I;JY(+j@6+i-U3JQ?QAQNKMo19g_7&^!5i3wd@|2jAjwAYHR)GdTZ3KJ^^822mdFBq+~#> z1)*jZq4to4aoi1Pr~&^r5r^;{x0GPJ#-Xyv$ld#|K%Su-^e7gDGtVjpIv)dXRGv%6 z51|~FF&p2r-}N9^;iVywM6oN05<$hf%vqWyKXFFo9bq1`)<=#?%SgKdk>Whu*n|d~ z+MB&OS7UY&hCbmBT7YGUb0z8r7AnIkXB0^Y@47`yH+g`Q;7#!WhyyD9TDd%QQ#Iqo z^~72AfNFWDfTRUg|NUPzLoCAJ-n{zl!nYF1F7^O`J#^6-4ZRp}4x~o$hekgu{+ta` zvyKi@D5eDs^ooaZIvfE3@Jc8rk4GFLV=7p?f%@Y8iCfs)t-mG>@?B8JnaJrzEUELe z*8lJwX-9Jd zBUS$TCIqbT_;)#g)Z_$w;s5%L5{#x)ixveh#Mu9v_oA{#jz^(P)r%iC$wGoavq=y7 zDim9O?EASfrd|g{@Qj*^RcwxE@ik3aez9Z9^4L)1HCu*{sjrn?paxx&*GZ91S6~UL(9#r=bD`x12YdN+tipUTa8(%&>+laH7t{)0)t|Vxe>TYiZvHeHz|(# z;C?vnR7j0U0 zLcmY^AZ-ucvOx`t$4oBz1*p;}DGu2@-+}~Q(t6u)5zyl9Y>`8J9MtsVl*N+;BNGJr zj`!5pU#Q#%I^v#}1BDFu`%uXSc$;P~ybVef+zUti`6HnqAp^GRg5=J2QPMI=BOqMS z7^#>n>KmfK83X4}>oyx+-K-j0(VGYYDCuE>0`giMyPYkU5UI=%1TOWf{5 zcaFJ--pR&QO--o>XzaUbQGMlupFb$33~8Ih&p`yqDaA(c@^?QyQ)OGh3NflqMosPo zW41xZ!>HiCO43{9-E3%k!}l}3Q=sn4K?>b*u~_O z)f?XT<_TjDiA~0MR$Bg|I&;kxC(T z&@h1Ba}cTz>S>wc zAXh`n!jEyTtV+S`>;z}ghEo_$Nvac13Ueb0exhXpGrRbZg6L!;x-9cnrt8ri(016NHArX*@CgIxfsg1Vj{dA8zLG3o7JR*wO-Wg z0Z-^OJ8oRGz`62aC2+Dg?8O#A-^y>}Q;Mz!Vr(CZisDhY{xmD0nw%0vbT2i&qiQ~M z4u070bYETKHw5+GSavt-ONmtin`qvP;$>eW9m9Z}F6wuQ!WjmZUXN^y+1ktJ0Ss>+ zre20pbiTqYQO^hpUs2h@m}p!v`1$(%J|9@<7{IrCg7Ry-yAlUW!@79gruj%p`bwby?S|ah5 z%fjWjT#Whq2y%y_u-4>dz*E#w^)hc*$22@7wR{Wos*7#Q^uWD^Gcry^P`bPOQ71|0 z9jW?#fPIVd^akcjMh4Jr1M7my+i>SD3U5br^(&JZdVs!_((cF0Ko2yGsFuu9x~bVp}=Yn z>!#x&6^baR1}@ewlvq4B>C0mUB^U4{kiaw*4Icq?Lf4@B zBNNk3f_Dp>wn5A%@T9Yr_65lkE;_))Z=Z0!vGwp9tGed~C>pNB@^lM<&^0F;J|O*& zPzA}4wX4*J;aH^5FVtx)1rx!lx5@!gq+usz5{H^>IYlL0UkqFy@-Y;G1HMfinI0S% zMHv>@jREov%U-i!cwz8@Ae9vlnm(ZLqAk2m`<}gisd%Mt zWTk+Zg8`irh1i4Vy!5GkjVRNM%n%9hmx0+-hNKo70GC6ZLH+HLebDNj0Sen+Hpq+W zO16WeRpZAg1mMw(=ekP3g#6;t!bAeeDo39 zRp4bbA=C_4og`|_Gz-lDyw}YCbA|LhAn{0wx4?f@+S_$j5V88E%OnXk4Nm7x&Jk~H zDowvP(DX{jUj+}+HsGOF5aL>gH!!Nh?f-$wVW z(Y>FxjTfYJe!2pbq2p4rld{d)F<>xiD+6}H-kMlKTxD72O+V`<7~vRTC?t~oA)5h2 z_`xf)Q)%H!sGlxcD&`arI6%?&=!c>K(PW8R2?EWSWNCT;MH$70@IQyGsYsJadN8D1dW4fDqR{^0 zA1M0cgX*X$VFM#Wkm zg)w+BL(WNAiw&4MSz#@k%l{q2o;BryBk93eXyRLQ)b&7ai8hjq(f%&lmh{>TdNOqz zO~D86*TXjKrLwGX34VtY{H_9%>C&>p1W43Mw?!cGuSdF!jnLK@-m*RnZY<{1Hn|U+c3p->{-ETYRMw89ik1rP^wfflrg6ih;SRTFHo zUYXR(w^y)^FE0BCrhxX4L?0xzp~-_N(qCFXbset>cnXdzkh`LgASu=FG2blop@k(f z2*4@aH+!uGDOD{nDv)GoDj>CwQ>}87$L;JDc3ColmtKM}w4<1u({?5-@AZWCVC(PT)#tDUe#2zp^8s1F^ zh5ZS=!}fq}GS*z8=#&fH(mD)A=F55#0}oWvLzd~}Wt>L`1sHy07D`XJf0%5CI8W0Y zXe`&+T5wp=dAB!s^`i9N?g5I;+~lo*#8dl|C+YNXO>T9OAE;vx`bcw#EPQuDztUJ4 z?&-c8vf94b!NtS?#YXnBOf#^VkNLLC80F1B5&!nyB9JX<_& zbA4jf!yW*pv;pxXXc%sd~M~gU3`Tg8!2_J9N6Xu{pVUDH&EwpO+P_%tAHxsM-M0O`d^qqUf)A+Jb`4Ay?@pDoztJ(!}sb!mH}l% zGFp0A&j?{530!_COVgF8P+0gZ^8N%q+VeU<_<0jN$ZjG0_!t|+wLhO1A9&sd=UHNB zXY948y?`33+M)=G`;-4Td630K7_N#g;X01L_ zNre@=gthj?{$bSl0j7XE!h?H1^dzH?kbe*=##?)<^CKxQXICw}fA{B=Lyp-R{K z^wUt8Tsz;zsOB)(X|_c_l^CVtUwX{b@WY1F*OL>Ef8ANuTpw<=TxkYcwBuCpxTCq& z3IQ!U{99$dc?as&qoKGE!?o}@WFl~Abvb*oZ~Qk@`X84Xzl7EYy2-qs|5uOrqd%nc z&DG}~Bb2?r&R4;{Dl;f2OTZgjb17L*YPV|MPf0mht%)XlUccK&Cy;+&{fEmFC!3>l zjQU3R<|WJCfn3jL%a#021%&2p^Q(8*9|)VPzoj8FwUEr($p*QETBP&++J}?ZJpYI; zseN<$YNO?CZx6e2fgFyrc5R^96z2N%uOSk7eZ zGrOMTaec8a>^`tEHs_#?mdwPdXA~iYUKrgK2vCliQwCjq%?=Ijb z+UepFjZ;Cd#=uI`ZqbuT^|ue+;Xsi}S8CV9J8)mY|2E6Tw2v$W;f#Vx0pp6)frd+{ z;^H&;h$cSx^;U|%-MC(Q@dr4f49Dk_$J7SW~7eB!?C!tE4R#D4pdaREYcTGv*AR!;9E3^H%P$1Xt+#Yqc?kza@YSY<_Na&z@0=MqlGk$Nw zRDOi*0I#Whm+*}U8=%^w3qvXV5P3|mC;pN9{iP%HjPh))AsKR(E zQGUT}*@7*-=A7Rl@5HhjsJ=Gq2q&8wYII@G z!n=Q3O5vBUIVU%;u16y%PGytfIu|kQ-o6-~b^{1`YJ}Z4zccVg61Q66hJ$hHA}ExC zq#Ykm$ktN7|MiLQSgYy#pRc;KtTQd)v~iJK7hh2sal;^RF*Tcp*y8dr?~PZS!dR&s zB%UF5E1dt@M709;T;NHQ@X`rTK@~QO+r77&c#lPU3Uw*b^7*My$o7vX?cf1A5~41O z$jnVhngKEXQJIA&3lk#t4ep61N6|yB<3vxre$SiDkfk{-+vnv6<~MNDzIq=G40p@D zW)0)u;P7>lOT3cd{a3YZO497(1?^0Rtfaa9%`>X%>TNG_6z-C@34hDih^^jUsn>El zPE?haTTz%ar#Me1qJE)^gW-+Or>eCK+(i7hZ(s7wPCpuW`Pil8C4O>wJ!l)r_wm4$ zqAK&BE+yyI!P^JpzJo|%NqH=-%Ynh#i>FasYx{!9Jm-5WMw9?wkivQe9(yJtMRZQv zS~uRcnYO4S*GMLEFynmQ2kP0jh=n$R#5=dm&`@za%*?f`_9l<#n9>9|z81m)Wue;+ zV}`xB{%k6t(NKQSNogs` zr3k%=7-#3Z;TGD4XxN!^)CUvC5K(0>B2?@ z_72w3A<^SReL?6OLnU$M@GHO8!Q=EPCb4SJJW`_=6R46(FxrH+L(`bAJH@V#MSoKP zMj1+&K2|I<5GhU31Wlx3Y_aVpb;2okzquiz+{;(xF!3+oEnUCLq4BD%vET|zwqwEwj*vdp9pGq|>=TP3 zGn5oK2i7>#TGr5WrpBNjwj9M&He`SVAE+17ELAPnvM&!?j8V@#0&jBu@S92F@%%$w zPAKyWaQ)_Pua(34GF!^-X;$HSwR0CDS2x4bQxizV?8-Q4-mRETZnoh{T%`}RDVUEC58)db|*GnNF7PtfiIjL@v zR>X1ixwgc^xybbO?KG~D1Wm(K0{w*3EMfSVD&4DM>)YwN&}X5KK9X{;@##e8z!BIF zix9G<;z_PuwEMa%RE_^TFE5}M=ATON zOx@T{b06NX@60v2?>tzcG)M9gEN|Fh^q-YHPK-~5&fL$GvMY4-6xZ@^cI%?$oyR4q zls>NP1p|cG2W#qY+xf4p9WY6n^=2!%%QBqSH8(3|=>AEh(#5pkx>*SZNd&VpEg4^~ zfQdi(uv#UZ^(q}zCWW*F1ZcF56IG6f+(cYlo6nkKGaiX+T{!Gt{!@&P_I$5&9VQc< zr|-<5m(DszLcJBo4~JzP@^tOW;Z_<--93K6k%T@_vlmp;nKt||+^0{YgckF7{@7B@ z!}!I}k8+q{IbQDM&g8~z@TQ@{)ij=E;+uHu(bj6JL?AWl)c5s4??q|SC^hc3o~3k4 zNz3QJVj}zN!UZhEw;ll9PYlZvztJ8=-?e}fUE%>R94syyXm*-)UXE0}z}7f3J`a{k z9OfTnw)wG?hT7!%07F{2y+n%SZVXn^Lck^8B)%u+TUa1tW6c)2gdUbqK79Fxg=3n) zMrU#q$E{bDStjwhmn+XpG{0#;CmpF7oZi_%dXci4l$WwyP;ZLO@jKvU>S=OF=y zG=W#}?rufMmo_{)H7c&LeE7;>%C{|XOkn!~POF3p~N- zuxoS9MZme;IF%hCFSK$u)w|;enx%5%-u^=+Os7Zh6j4zRk<-0#`@9w+TA=R%-!eJe zW=rRIzV~yhuh+xwRCG~k9z^8v+27y#REOn(eHg6J!8FR^r&6nqKJ)6kO6Ge_0I?8V zm1+IKX&U5HXEHs`AW5J+z~+?dka=){*KxS(9y+<-UR~63Y}x@TnSD`Yhi9Gy*o6Lr z32C{mj5BQdT1e*JG;*kvB585oS94p(86K5?iuQ)Pf7(Ecc@cg;06I#*ymja(c>OGa zH|UvS_dD2iHdaX_A0o6Vh9#4E0l_h{2Fa!(m ztB)moBIpd^R|_G$C6}rsmuq}jGHaXmyuoo#(`4>a9H$Yw!>n={aQ>N<=c&Z~@Cn4% z-s$cIv?CUhX=Eg`ZHDFBOfcEB5v3ZD-k5)1*r^MUdtm{$u)L){nGXNn`p%BQB7MeX zNrSdrjJBgdKOmu9|LHY13i0X($0i02u#}wdt-EN(1v88NBV3}3{ZGp=S#bXU)612N zz3BRYIQ^&P%-diBK3`nLyQ)_lzx)}3U<5IB{`c7qQRr-k$t~X8hG262TzUyk7rkGj zM{V}QE89FU>d56vy5n3<)R$$_V;_e@2Yz%s9L)1?MD`U zKRGg%CJ5~ganCzAFSh6k=MQlUwAm>Rn~__I>fEc9h(E|fXyliGvzX*^__PvU27O>} z_Q659Q}m*bxoTL^Sg;!yoS^EQw!UsGNI|D)S;% z_Nukuj|s934em&mJ+p^XS=Y?=R~&#D_yXvJBBt#7M@3nweQ?t1?J%^gb{!plqe!CR ztsFZ2p8+DFN;uv*WkrMHyXrz(Y2?#cCTySUDOF$oXT12PJSx6|IH*sDUIpy-=;Eb| z#pxQzE_?nDxm{yjEQFW{q(DRd4ps~{1^}whDpsZEsM?iT3A<4eRW7}c6PIJu?bXxB zeFz38RK(-?@t+-Tc!DAJuEsPeY8e7jh^0{^GaA;t<{Wzh%8R#D)S~OyX2t}CLC~uE zEr(dnH%pIH#@LWyxEz<|WZ=ROo@}NYBN3DJ^BxD{6J{U{-jjFog7V zw(xue0B0iYDoEdtu70K%z2(?(BCP`8aF~OC8f8AaAw4iV<5^!V&|y# zjZ$DHtU@~D768AVo8YqpZQEKc6>U2bY`fxdB~l<8(x)4%;xnFcMOqEL@9P|NxAOIT zOfSt`XR?vNbnPr)(w**r(Je0zDQ)_*=gq(am44O;(~e{#o$jc9x31b$+qrMv6S6I* z4DO+gWWqygM2c4^L6ed?K)xle3UdEUwDqz^cU7KrJ(>{1yV(tB=Al-Er*_ zmH0nHv@%$JJ(9`Vpkt(qg8;yqx*Uv|n)+oJb9ex0=H7ZWcR>^Me zHlw%x-ILq8XBAJ-vy4Nl=;ieqsF=H&g3x;mW9=SZTUw&{FVyRxMa#yN1 z14KiV8_r%IwNi{F(c4PAk41IxPeQPUWL}d{e)353D+EG?`e0JJu{OE^v0tMkM=X#0 zcqWH&#dg^w_alN;_T6-%Z)V2jU-g93Sfqjf-~6$cfcML=A+YZ&l;DHg2D$hMsiUaYO0!; zr3O&2e~9{yBm=zDbOF*y5IJf7LMKL1?lG(n;0I?&b~fFHLes^fbCYX5m;HNMA*=D}rc zvJ9hepB6lQ*8elOf>5FqS(-?p446U;%!t9|kF?U}U3)HR)p%vFOW3!O8ma0o@|h=U ztOwDo00=q*RL@|mnEnmV^hnC8$nzrqzvdostgN)u&Bu#F7-1a+chi^x6ltR)FpIdj% z$)cW}wy%q+wOt6}bG~BO%;)U~dd?|&%!=NQ#4orZ`BVZI$3E7x<~?y)tcyV|v67RY z;pe2_Pw4pz-{*A>YbB0p)uOFz(myW8)1$!-*Ja6Txotf!jEgX|Xq+@j(r!|LbMuO9dxZawb&$*d!_trA zIZnKuOrnhziXv9B7iLM>$yB@LA9!IRIQ9^B^|a6Y_y}I1O&n6rwJP3V8fh8e5MoOsN8UX#r?t9yW#fol zavJ3b-fv@XZ;)LGePITG!s#Hvvs=#gXEA}dV~_7PDWu!&?aOdZ3%)g?wbelV&SlJM zi6ZGGr7t(0%!FmL5G@&&{{KLoz%Zh7b)G)A0@Zb+$hDWEx&n&h(R(G7$9vbM(kQ0S zl}UEE595Ksl8ez7PuRXM`xk4B-;R=5Il+N-cvo_uRLy2a@kY$@<)~0iShEAtOBjuz z-24;6DZj;Uy#8o($MeR}`@qTFySCqQQ~Y0WW^Ys(II~+ULM0!oG}%frxtvN6JU+xo z3vsXPnYW(j(kSQDl}L}5{ul{X9l#oQe3_P?f9uEMDNhw;5s~=9S@y(MtnHw+4^BYL zjK~&gvZ9AB3isF=A2)9XOKf-3^)>R0$f|J935+1 z)p|NILCLN(CwlV8N!dZT61g)xPh(A;6N5X%t3q;&>CFhKR~qhhIi-3cc9{L2M@p(| z_go2P^iJ{TBcu_Z1f&x}D8`=aT9jx|j3t}Jfp-RD9I=kX++O)IlaS{}vF%K+er_~g zcDQfssIzjQI_0VHE_4)xyRSW0B;;d4Bt<0L8s)7WFgT7D%xcs}h2dcHVy-BljOuru z*w5D$rxzh!g&$>EmAECex$xH`aO1xJ(T#vA`>HvK;qg@4cB(vBu~&O!x21!UOV^DE|Kt|m=h;OpXqUaMY4&k`x&8f*wClmkGsC`5{WbmO zaSs0wFzD{%>L1>}mwIi)g#X6f&M4$JzaFVXGVmpkL@_J(V3w_zuNiCH!gl8VGKneS z6Ttp$Y#)BL_SiM*$Ag9*rK|s9R^PgIJF>jmN?EwbwLE|6l~Zz1*1+X49~rlbbM;K} zN1EKc(tE}G101#Y%m;fksT@ntdHnET7Su-m+mY%=2L~U!-x|kmxIvdMQq~w$q3tKVyAnOG? zrYmN$?jyyoY?vMmDB0C5DeZvp;Dyz!iY#W;>E`VVk|JDJ)z#+IHp$;q=ov%Qn17$y z*PK7<70@e{ZiW>q)_j}I=OTM%G@t&MNwL@@Q&ePks*mmO>Vws00zn_|`SVTPY*z!g zc?d!1CNre1`ne%x6EZ)6QoXh zb=1X{Gwv6eQZ1qnv{+v1x>7&7Nh|B8dhNL}_g&^o8+7%bH+&pgNs)X1gw<0l--%os z^IT=2yK$h}`h0nK;44k(6WY)Cqi+2c&Ko!4w^qMNwA=4O}t+>D| zzpRkF^X~^UrcW2>&tCH1?dr@p{e8O9d{jH^=()}L*Lp2y>I@z=MW~;$PCJlfd(fEJ zr?q5W2{s@lS&09WNCVeehJ&{pD zty>jiN2%9-?DaRq@F|{9GNpCXZB;FqEWn|i#ZN=Nc(U3{c;|4PrJPAg{oMW=8H*<{ zhqh=-)xReDcmfcVRxKE+DsQQ~MS?o`u6+6DBh;px$#(RO; z{Ep*xNUI*%$TmZp@8ntw0ywqYYS+smssb17&j8eW(8wA3@beFD(G6eV%QPu5C@~Rq zbdZ(?6kkUr4!pu-h0D(wb`94ayp@ab?(@wMY*nQR)dtH zzPfX1@Gb3MMD$xnoLV2KxCe@Uxxl7iSyaHP@T6zt%C(`nuWQC+3*lEnLRWU@VA>=4S;GnSiUZ~8x(Y2Fx!0}DmKpbVk)1ft zzY>v|J}Taw)!el;tkrWfLL#TDj6NTKnXUf0+lBu}7<+)d3_oUNX9TxrM_d-&C*7gr~l-XIXIN4W``Xz2NX$De<@R;KM)*ZR~efS5H`!Sgh#a5mD?97rp&zZV* zsoXUyj#NvdoKDVf&h6zv;W*`Tan!>nXHG>u{C|{vbzGF+_UPGEb~YdHY^8=jb4m%{t?RdxjEQF4Pzj-k$9JecA2@vCnk}eZJu=JY zJB*Y>aB}y)p5svaEO_B+W(KItpG!A)UT6pnis1@_dvHrG`k(H+%l|7PX@ltrw>VVU2o7)&A5>LioA*sUCS&44m}@y*F_b-H5YJv zBzO>>41{`)&KzlbR|Z|zb|0B#ZvMze`BhcbOq!dV*Dp>MJ%46C-zM-wTQ3Tpy5%j9 zg>Gn~Y8xL7dMy>)>~LQ@G(c58W}}t4ni8k|E^0PQHbJnMJtfVbFEQNS>Uek4k;z#J zJP#NvNV_+fQh&zfJpSxz$y4ih)Z)JOPXAsdu|(2@`~ACJ2>rK}dYpk3OdUGwIFD z2949bLE#I^WoH?u5S-sP$hfrScV2{&Yr!iuwh;FW=>u;7nrhg?D5al@wnA# zi@yf=z;W2C4}f8AydC7xA;21*1AKS~kc#K_eWN~w-}hN@ zUp(`b1}vT!cg(Gn2%zwD`Muo?je+fZH;*$?jv(xF0Z{65<<{^V-X@Y~sOE7?1P}xC zt!mUY&k3&Ue4Xh-oHG0JYgpD9#ogD!c6(-M|0bLP>o;GM(Bh%)r58mGD_%%5DH~!y z<;Sb8mmf=JG=Mh$#H_2R4YpQ{RmQb}u=#hy@@E7BcMgh-`s^?0s<;%$_Z08>FYv0S zj6rr$RyvxlKtn;)2Dxch(3c;dSVm>Mm`b<&2jL&<^Ge`lSeXD9cgkLnfGqMly6(0Q zqnoPHP2QYv#+b>3&V9%r`+_@+J9X({IBjSg$TYqi3tS=&WAQRi>V|NlbdgA#&}48&6@wMs69o2wqaO)5fx z;8+o@q7W)due&@pLhy)k-zJMeAb5^MmJXX zf@=xC+!Au)T_w=W(=wrzH8>9Rwp7~hApA8l?J8r1e9H{U<$p^HioPUsDn@1?{${Mo zy8U4hs5FAHy0O_-M@DB(s5+vw+($r51GECpK8s2|lq%UtU}_Xys?NR~y9)+vwAZ8N zejl|hZ0;9al>NmXw^`_l%jH(oMU z;AScAJr0u)i;$1VK$L%`lP^x#xcGRf-wPYCSU|vX3H~6zoEro91Atl9@p5G~2qQjW zF`}S>VzZ!6Li#ggUrzzVHigvCd?Y09`xch<_4(8@$Jei_dIF{J6rJ&PQ5&L#S*xY- zt`Uahm8L2eKl+>r?PUAy)eDSY+lbxrO4(DIp%^l!V%PlN;#J2%JE525fScpeiaw#@ zuBO-9qHHs0Gj)o40_tZAU&O_ui-kBGp4U)-ci0}k_u#0wadR#;veXF;*ELJRtRZ{onr2wm z18|bIyY=eaABlxQ+z6``%VKt>Wg`0jrWCp?AC3dX#f$HKl3nYS1P+(PNJ7}3HjdGN z7XZGYT1&a?2ahwvq&riP0dHdsm;&FTdXY>B;nPM{P^}Odt`mL3H_OC3$2X)lGGDSr?nnlo zev`{A@(AS;2PX?oppM>1ZpCN=;Ff@nPWjb>^kcL7hdnCpeWut9U%EQhS;y@#l6eJo z0TckQo+^tH+yZtCgwN5YdKfC2p%HQF~?qkmZu*;L{fyQNFC8{!!zx{K2&#z~asdzi1vtlw(RW zpyIxvFk90#w4VN(*OZ;*$-AKF95H1VFHUz$**M4O=6HhA6G^Jitcrk`!AFv^N>#;H zMaDU=HW;<1>YrCcQaaWJ-dqa4cqA@FFew@8PG85rDDn+aQQj~t6Ci*i@W(*kVp3U@ zRRG9j_3i-S{P$fD`HdCTAzdnysBjnFvjo8nD1VjuUMQFlrvVhiU*q8!UBo=7wj#eK zS*{!>giu!ge_=RjBaDZi&Cgl(5S%Rh8rDdT=b-DJb_xlqWM2V$+9GsTv=27-;NNe} z7u1i9xTr$(QTAo?uVcO!%@qwYSq5O5_iRMo{129o0Cn2dXFW$s{4VIxr+6o{!0~7- z$C)Dy>4*6fo`vV4H+cEp=>?;0L7METcZU!(pBo{oEihE_e-(M^ww@0?s44XkoZJFS z7u+9lo=uUNbS#+ggkJ{W9Aj0)&V!CBhYnrd-@g-p8+r(_c)`r62ov&!qw2#me4)z3AG=Q+ zv7jPQ(B6HpNUIMzi|*QWFSQbQ(Jp@t#2MMK=xQ{b75S!j7^uZ>SD1NXF0sdyqkoix zEEsDne9w);e@6GZ34m*uSw6^*GXGnMv`ki|871x5Zy(H0^i=dFSIIUuGF!Pxx0dQa zxzeT{D%!HM1>Z7^YeNj_fHMP?8y|h{*mmarlOYbg{Kwk=B_};L_tfPo0z&_ojnO?( z<^y1fp9pjdrk15A6$h-}GXM_AIkg4<-2-=D>eDy<+$xZu{SU+oQ#DBcIjI?$D6c1j z#E?5iw;pACz@s~;M)v804 zfgdX5pdsSxuc=#AR4*3Dh@6vuMophqpE$h)Hv!?JN#Xx@&3vDw!YGW^mmD{V$X!9F zn!qdOx}8w2PwSxBsDixeN4l=v?=F$MYX6TRyj(6F(UtYAhw%CZcdk>^!8x43!dokK zv@D53Mh902!I)tJ$44EzK-)(c3w$s0AFBpg_9azJHRgl7-u-@vasu-%#K3xv>>+JA zeN36FxDe$II3%_`&-851?5vGP8vBh5y>oc?wC!}+A7)uQLg)Nzs?I3h?TeNmgBRhQ zRgAz?U;c`B2?`JY$*s+ZLiV4i9SAJa{=ftJdp7zYV(7B8dvdow==xpWtNeKZ+i%Kv zA3S+L?Vnf?9RetmT4Bh)25P9(f7J{`YW@3&H_&LKw)@#s58L%nWsZ!-QcK(T{ivww-y{B;hYW$~!I){~-|#@?8?{*My&; zXY%VjL5E^aaHH7N5}*HPUz0$6Z%ba;{5jXMWX%~cAa)IXI2$e{L0=6ERA_i_4-`52<}Um(9R>1DZSOu>xh z8Wjv`22~0peHj>)kfD&>nwNspTGWrCFqHFn!=H; z^sgJEMBsG&GuwW%&q{kx@$_9ua*JW?qEJrej^lYMj2xeF|4#wzzo*Jm z?`D+zUi#PCZc+CUoSFhLEeY5_8TYH2sxaDfmiIwZ*DkU`1!@`U0|Nsf6cdBOn#IY* z6QDQ~%(bzJm2K5PJ4FQ|Ay0sR~*IcQC7Y)I#7(NozB(BJIu);gH^7NQ7-w=m|D zK4CAm%hotKjt8rPjuk*1sIz;+7`ezb1wHpqHUdDdW|8M`+1oZEqTr=CXf>se?xk1I zk{NFwu)doj+@Tao9edyD+ZSLmux{*={z(Br5l{Q?q$ypw+vkujs7#vsWLQA5aIQeH zNo$>4_(Koz1lNZ`;k%$YokCX`r1oX0pw;hqhj*Z-&T+AT1I{r}00#eqiU;1wvo9&% z(nq^9o4c>mi%=0=22PSL>ah84`-0?w1krA;1FJu1rY6;lWdfa}R-7kc1E4Yr)q26I zhM+?Baiy0&c~%ZYpwsUd57AHf4{BE4q%F0q%~h*{w(nuZ)@^vE@QU+oQ?jSSD>*-YmD+E%y&E8kIRvB3T@p&APUkI}VJZu%?j;4W0bKE|x)k(2@@{3K<6|ACks^ zl8l&_ZTM)vR%9x*B68#DXlBNd3`4nwP~b1n%HEyC*YZ2@hIz7K7xt%22&n;EUdBb& zHF}1AhS+8gzEH zs90s`JXJo&38dQ9y%a!ZEfgY%f>F^qo*`Kit%~Pu4iK(Mr z%YH78yh1$wYbrziPB5RD+&yZk{VqcZIB6Et6C(=tYsGyv3Ro^ZD6hT&1$^w;$Ok7? zxqcRt#qYI*_FmV)5X4>IeMgJKS=!Zz0(utEd{|$6euw#(4#GsBTuo8vx9$IDt|r1j zTF36yF2qjw+(#OkieOyS8rh1M42!=QLO+nx^PKTeQnP*@^k)8CE(d*hC;yuW<=N*h zhgfNRFE?~~QSCViI$zV>$_gt*WOq*x?S6J(-Q)yTa*fs9A5b80O#-kEsC_h9 zPX|fwES>Nh^QjdOaxgi6{wJ{iyF%HBc|7mB6@#;Dj$lQKB??NRl{i_a*P*uAXOLZh zTrMtUwmpGG2r`U6SdAaLpK#mN?0flyo0}YNJ_v8q!{z(A%9+#}rQ*^APa9tthBfx* zQ91Yd*GE+?c!AChHYC*Ihjq>#!_W~s=!g$m-XA_1cpY9W^TbMm-(+`UfanATXc#3U z0R0pn8x_hqWRXD*tdR{Kp`sTgljFZE70Z`f{e-#>#lac)v4`O*_CwwPRkEke_I7-1 z*5>?c1=KwxgoyR2qw{fK)AmpzB{MEFW2sZgb!P{kNI+hQv5jj)V{w(B>gR{VdW-D( zNSidM`Fk2}_PD#QLTxs?JL>%HZ%(&}`)VFUarf1{gDN1$Ec(ZKdDfjbRkSk;2b&jT zq%WKaU9)2P0!6^rk9R8QR@oL!zk2)2@3B-N^f67FJRhJAO3y!sM$8r$oFt+O%dIgd z#`n5-+=|sDcK{oop)hT#qd=`$%~p4`9&xc|{E{LnkryImZBYsR^>a)rT%5Q9nkQg2 z1NV2_`Q&eqi8TB#sR^FYbs=U21@<#0dWR=^nLSSf#M0W&tXw<4&7PsnOq}&C@H7iE z?`#mY=mm!ExHbQ^fy*W!zl1G-14Aew1_Cy1;RVP3)}XG`moi(&hGZybj@b>Da-+m= zdM+|GHedoC$4OMOQC7IPXY056jLrt-aQC*%W97X^_Y}S(Za}Sv)zR{Zn_J12J*CuF zvrfAJt>OL?8o?oGljBGg-8Eg(j*(ToW?dqc+9cuE*&Z`S9+#8^X9XlC-3D@KBUoHI z7{nTIj}#3LKjzp6C}19Z?hocC`mQdYI$o(8pa6t-w08@wwv@=%m`{SaB10$NqjoS5P1d-1g*0m z8l@UhO&=x7F|@EZ_ouk&eJ|hlXQ4aI3aD7^t71#!-~-k1)5Mcm1&I=)rSMt2=F7>YNvM1a0C; zIDiyXIzO2eVrc0+tG0d_(VrA#y`>Ac*qc12`7qFd^3T!cztp2_u`b*U}S4NupYam{DdTdfro?8IAJ~HocvLYPxKssXM{n@{PxYYTPw7XJA z>HPJvwpgA?RbkXxsnr$UyE{t!@5>*b8#{^|WL&5_BH^vt?99hG zvDc6!{5jua+la=wR&9B&BT;K$D3OXV{fhJO8PQlTDaS%+43;DCmZdC~4R|;E--k$f z5xOqo`j_~(c}n}dVAK|ba7C9YjL1-W|Fz{Ev;oRtxtjBi%f|wnvK#_a-p7c z0_aDe6L`q5HQIZSQQH6<6k-e5m~$3IrffRSrI6>sH)9GWK3^=%G9}mElKtHafOpCi zHJ-ggnrwfs62I~u_j$1tjUY;6FmkG;5^JNg;zVgeQA2B0mOMPm^J6V`x z8sMMhOUF9DOPkdbC{ujz|rzd-D+EA1{G8N;r5 z=2y59eV~-yBalGCJeU4hD4@z)z|k5c?R|+SI~lx>NITr;<+x6rz63^XvY$hKN=J8e zP*${bko=+PvTE&hI-Ac9+PB$HT}5GXXVe^~?>Q3U8vt0Kac;0c0IkM-W7*Qk2v+>j ziycEqM3BS@3ov#tZ8x!2*y-D(T)EPvb7XJCF?55NjVz=)e?n3aXSOt@yzD`5Q|$-G z{!-&rb}*jP z_Sb(6TOKq_P`e)ld0o+ElBm=XVzVQAP?R-mW9oRbC=r~(fZxii0bw8XdvG~)>AdT2 zXF3=4fKSoG|8}u#xRSFR|3mYL|FCE#Z~=>n8L~ zAMexnwcnG#rke8}2a96+E1!Z6^v|r7GOtw(SbvC>2qqXrGiY8d8K@hXA0L2bI;wfG@cIQT z8d~f1&r(NI!pss|P08;@&nxS=bL@Zf1hYqY#HC9E>S8zOTx6aspVo2|bzug-iDQ-H zE375J({}BRa?5sdt&G`|d2oY{3eKP4QrF9L?yAwinsH6U)pZskWYMddvRVzx-TO1@ zIN4zSj5ZW1@!XLv^l#cCGap=yxmYJdH>T&;lxIg@$0K2{yelJcp(GD8Lt*q+hUl6Q z95u&txJWk^biHZBs_y}s-9aC=4yFP(eqgO%3+U_gpo6OkC4oDG_s${Rn2^kq_mtFu zTjnC7+) zT1xkzlo1qwoJZCeS6fr2rhx(ld&fkHc7uIg#l01dxIvyw7V32xGf$cTIyHxG%5A@o zSR7<8`w`cz$MI&&MP|~AZp@2rZQG7MUEvHlMXci(>(B(I2mHMk){-HbzUe0^%RRqx znP}JdcCiHRn#68Hme|EwhmY|eaeqjI>64p?ZDYji$9Yyg)7& zAZ9!xJ!wm~rbHCtVO6gcpKHeNr0Y9|60x(GpKEYaVFo<>p|so%uZnxJ&gfTji6RTi zA?H$->u3e{5N^g~fmeGm)5$l?k=*0SEYX0&2P}}yf)41PKdZ!O>Exux-RQyv5`5An`Q zam{~H7F-K-P@o32l%C>D+sJi!A3a7w(Z}*aV7|(eb!oVYl~|8+lx8V6e7=;RM{p}f zPiQshV;iy-(eAo?wdyKfe1zq}*773`X2;rY2R98NT+ApeM|X{gR^?|ZDZOy_)t1K| z6DC@3=D{6BJ!8kl7m+KY<{7 z*3vrH%=7ugd)4}lo3OBYL<)AA?CXR*e!tYvw6|_~+)da|aE%3wKlwF03C)0=-xJYA z2SZ%74Z5X*^$T`D#e5!I-3{gj#N*%IIxNq;2HzgV!+v8Uv!Omy63k9Rqaue|MN6!9 z^Qfq{)2Cp`z&v5oes&aQ=2$~Ft8K^|&Z7*VNcR&OYOeTX zos)KKsNzT9m3&2gaI%ZAV$6tks&$SQW|r`0A;CteHxFge*jHHfO)!+qQ6!zF3XOhi z-Byp_0M8X%FZG5B4L#6HH)`i4`}@IF8Lf~()JHvXbk^~82I(a@cOryMBXyXuefQ2! zwWT_u^85&Crhh5IszDL{#Czr6hXi0!`ja)j26+DuNLi8o6ZK2AAA|-=8yac=$bC(D zqEJPS4u%FZctOPQTmT}!F{y1B1wMQ|{zM`L;9*HuB)Iua8ydq-?k)a~og%XXmSDzj zQTO45C!p{r^rieBCW3=N^`hWRw#Q&AcGLFwP*oKM-rCgiUv|<038-zYR0TV?2FW~z z+k}KhwCyk&<|zOzNEDptPTY$qu7K`{@w9k~RdpDkBe1r~-&z5S6?c1^A%xJbh718J zL%?$XQVR3y(9^LXjhwy%vPYN|bRSCO{Rx92%<$0H05m*ltQg-17xob}n!wbqYX&?8 zoub2%r4W;YeT@R2L!jCSt%tRoEvyW*)ne|O`1!nnTm_f{D+K9AkbtIpidC>v)ji;r z{D1aB3e6oV0j=Cm!Ms#uMlc)vJGw^yVK1rWT>JKMJuocldCtqKTT(B=d{|@R z%CFMFR8E3#sa#+pgwJC~ zNSuMcL9Tfl+H-oS=?8O*((A2cH!!si*oKpi6l75x;N}Jx!-FwN0Wi6hTni0C|A_MY zKnAcWd+Rhz0rHKjaJDc6X6E0Xt9+}y3Y6-8oPt&l-6Qb#t|6~sCIf1}EsOjBGOY4J z6P%*wz)^cx`qwh*&tJ}a!pcYlW5xwv;iv(Y0Z;-MjJOtdw2E{T7K{Tvvg!mMVMbUX z(1A=zoB+J;2Y=Rnv!g&V+6M>g#zDv0>4vn!a6_9}4y@JySBSn)H}A@rbOrpw@wv>!*%8UO4q69?L63+AE(HC<*fV{bX0buq01Tz&}K zduzViH}3UXgNrou4D1pJZLrio=KD1q3^|ukhHi?&CLt&q`t!+eIR}G^E*Ll+vQr3W zzwe&;;{z1g$bnTvc3ro~62K9sg3N>o&gO90#u%ziz&0Kazs>oYfiUI*c!GT&uxl)y zq=t?aWC1LBj|*+WYlN)eKQXZfaun7!tp8!gS5;^L;($v&nrne@RlxZw>^%fD7)guA zPiylOjO{WL&4k)LO5ky@jDAfYGa&b+5IS4&oc4867<9S;qa@kin-80QI4Z({ zvJJNEBLLrhue7RRdk}T-_{n2_i}tSu`uC_|feOJRXV_!*mG{G~x)FxhXO0hd8o8y_5mn~7tH_mQ=9I|aB9SnmWCNP#~|y~B(=gwK@r zfAJx-Ju)V@C6qWJz6&&4ZCxj)b!ZI*87`XboB6;eV5uz}y5*}hTvgs}?^j}ca)!ZX zFF{u=QSue*3BABS7G*qM&|23{30nBp(R1)u7q=@mW{D@Shk9`LtKO>!rYEo=FgOBY zb%vlX@NC^a?=L9=(z0fj$)Qg7wZ;n|F>Jmh2UJ&$8$LcS!(i%T4b4GLQ?odqV{WN9 zq&x##z-Yv)&0pF!ZJCWe@>qGM?3$)57Yl~U6!a>w&^H6jDo zV4jfQ{V49mXPl~F3yWQ38eQ&#Go2A5vh-;8zeh<0-iW9_WJgBbcAeS~`)bIch7r6T z$iZQ9fEsuj;-(l6RW`IsBHy5kwVrlS`m!FnAJnEPsRcql#k?9$=23BBQZpw%!qvt$ zGg{Gghhy6^dGmJ1%q!^WN2wbPzL3~@sII;2xg(4ViL;Dw$A>_9%j_Q#Pn zfynNWf6k;l8+gKjK^U#FX8$eDU)}v49Q{OUKvxzvl~O`rw*D1ZMY{dq$*{2CS8ljH;e+~k4W2~OK!NA2sfQLU>x8| z0PqCnLj{zU+xC0k81~S?swJJ(r3~dB0!Y`PPKW++44uX6gx9@q-KyeJ7Vp0rIH+W* z<{b|l2U3Bw^~qjfo&qcP z=My_xU9DCqQt;*TYvP_cs&(aT_h>tZ-aj-14y%+c4+-S@rmjyWX^S6!3$n|7E%e!2 zTJ;u|mmXA>E{nMr@GJ>DJUZ3aaRHOm*DK$wWon_XL;E&$-tjeg+SpwsfUaZ|b&cHT z>-L8%GKSU^d@=kByS8=UsojI`9y;qDxAbxpc&PLw5%=6{vI3|o9ljZMj)u`}d%oJC z4q0*G(%wyjhTon!>Vq{`jmuT;-xt_Tituf!A_|G{Z~9iHSX`~imbo7xlQ_2`=kjP@ z>A*0`wy-BG!b-MSe^f_=<`S$8!X74H>wWR1;$@IrZoWDyoj%g6G>9+_BVVJc(#>EQ zow}CYokMfkk5B$hk?)0Hvb8HpXV*MW?Y{mZL8vSw_x2#fS>=sG;N1iDHpvgks7K^; zy}DCyM6CPN=F?~Cx@ihHXUCekuDHj3vGQ($z^R08>?DzZ(II-9mo{XjScFOlspcM- zRnQ&gA>J{1G<`lc7VLXn~O3Bf>+GD#-v@b)ZAhEH|4 zp5^p4j`f^8ky9Nz10}k=t2s07C9lwrvX*5uY3{u482vu$I&?Znr`1JY75@|4vJ5b& zRu|QnU4`gIMY{#f5u8JU+xhzgNr)VQs>;-Y?~)c5$QC>vkK&z%weCn{!LpT#WrR+8 zdD+nEPAny~O6e2k3ZG(gzWdA=XV4QIRI9XK5&6Et1-CAOKTItPpx6)jARmw)9@MU z30_;}R!ZMyg_I0wWb{!b#tZm0i^3EjDNsq;wE@kPdp}+ zcp~sB4`I&n_~%&|<_?Pne5JjkvMS#^FB{@O8>P~SHM+(HKX6K5w|F%XW%bK+C11gy4R=**Yo}h=C&}FXM1h<{__J?$OJWp3i`@>fhy`KdMldUK0LA zlh$mz(Uv*!kg;j&@I2ea&&*5Wf5Gu>ucvr}a!Mj&bc_A2%U=6N(&+&vDiLf>G|c5JQ{E8eTtVMWiiYcY(ZC~yz>+kRzD#Q?v}x+W{|M>Dv%xOgR+NK zz8VAWBjM8`&U^l>8Z3o*3gVL`vH^4c0zlPS*75J{&nXpUvW#gLOhf-iT+z;=0vD)4 z^otQ0GsHlbR*7QNr6ItI0ly(ugn?cQoj*<2^rd`y=(+_Sbcd2Z`)2quY=dKfkoJf?P`J)_Oj=T0UK)O!x%PLHWWeb=3Uy? zlLpayd8vkb)}I-25?xzfFm(h>b?H(i`@S?VdV|0iMfV_RdEjG_8K4MP&XnHAbT^|# z6R1wY9)Zh3V994p7Rz;{MY)gE`L&t-eMgJ6UaNV0>*iFfe2)XU(zfb(FOWI`GllG* zVgPf)lsZ;9*jfA=GjnQV^?XAgto30l;Cduj8}Zbk8&wk%knv+-NXG5qU(EF>pjBL+ zqC_7m1U2$(8z?zJ_uVj=#=hvzK-}S1&p<-|Klh?sS?Clj8{$)K#FEO2$WJCvF+zPD zixbUFeejH&00k<_NyxC8b0aT2B>;PsmKEp|zZ~b?D}?#+o^Jx9N~Z9ap}+;>~HCwrZ#e(BiSqYjE+1_4bE- zskNq09v`9of$A@;?ILg0E|DKsk|FcO^R93CgO^7mHQsl0dKUv+V=- zEup%n;i`}@_)QQWWOn^mM9?Cisc>PBz>RY-USfmk!;6U(UUm+;BoJ~@Oq$Qt`Sf|D za!fAxVAB)ZDIW|V=(YC23s0)3Xmq$)oHz$lg7z11FuJuQ`n@oZ^}^12+!s$@&47V( zC4Tt>9g#}6$eSx9j`Wk1>OaNa`><&kEOF*3qBn=Wu!pmtAh3_HlHr>B^Lp}{pc|T$ zt@krtO-?(@N#mGK(x3QDe32nI%Vk;jkiO_zv&c+b#a3CL^Qg)4=yFiBt<6?h+)V%c zko}hPmP?!#+rkbF%N<_j%KD!uizn@6vVADTbh8PS4mXc)zh<97-n5Qk0IKXDYFc|L z1inmu9zA3bchDEp_sT0qAcKX_3S8vlDHYaOwWDm{TSR@}`-(k`p3ZWF9v{y=SEY@O zFtVDhFXjRGk%L>ZXuE5O5qNIj&^O^-(nQ3)W|U(2sFRzCbOs6Bo zzWgN44b1F+VGBp0igf+Z`AR2fL0;==uYHlP&0!%xFNKLhr(14C7QFuPNM6c)?o!6I z^wY**$1hM?ZK2C+X0u$M;8+Qew1--9mKq}%(vi&E&x}ug2UlCb5X?a?{e$%5L7Fq1 zvAFCj;E$A@XlygnWxj6?w|Kp|PIHY2AKny4i8l)^7fVQg!r_&g?BXxL&?5nT0Sr^TEMEJ|5+s%Qca*^wTEo zWewNhmPXwS^T-a2h)rUd^hml(TIdn&^6Qy*gueYm#TFSV@6B>o^$4E!WChW#?>?K` ztPtHeq2|o0=acnGtv8vovS1PBs_lq~xAu1JUulgI8vgCzdGM!8YbGe$cXHrkR&85< zjB4BHv__`F*Cs=kO=C}slLC1eUYL4ExSOOwZl60X(q;97>3sddn&;V6fuL8|$4i30 zf09v>2!8mNtJK)=)7w~I6oQ8L(|Ut>u*y|;rJNC}G-{5YiET!8kXtv5vln!o&3x0oWsKVzIEt<9|$WGM5zHji@xHOGVC0dX|w|jVQn-<)0^*Hqv zoJ>~Vo@!F?;4^U2nimd74^uUwa{q5e0c^iveKK13nMUUW;7X1$j_RM@6*cCN@D+5N zq@@7%uNaoPws+6rlfI`Juv2pb%jI`O*2`sctRaWCbI&x!V1%VU|IEwNh7OL|3>eLjlk^NNcIIo4@`Zt~3#qMN%-Me1 zCl7n_5xCTcwH?F}LqfUxMlBXQxaC*U`QX42!LyX6n~SdKgen08W=Mquz!o3Q7OE)) z{=e|3z3!z4D`j39&h@vSr}b_%TDWTg+so|=!%NNG3+cO>o>FuEU^<}vJ|CPALR1Yz z2Wne`;$!^3z{kAjOTHrk!P%Eg+JpC?_k4ohvlNH=NOD7d=_PD0iG@(*gZA3mXrQka zK%}gtC4N$lgy~EaBR6CBVv$6V9~B*N1oB>h*RFfr(FLABvfp2zvHQ;{D=hv#~>=m2x6DDp^=O<3qVqJKom1q%)_Gy47SxNnN>#@~2P$W*=|c7=XnJ zDpxWl+rINbt-l(vvYu0mfk)q0c>lW>U^g8ZbiakL0Kl&h(6nZRYXgFAymK1=9yj>? zfuAU2zI;nmT#XoC!RYMflg@kqF(0!wSw&&m^Q5pPr%NfSFXh zXHn2{{|Y!CriI^jKmj7T1QO|Ul+Vrtzw`U`xxvIApn0lkgN=Wf%4)Z&U zN$N-5A@uFlDyR2~^cQTYORM2lX}bOaHe8wIQ~nCgYXm37M;F5IB5$*Be$g$N9?E*+ zHjKjhnU>Vk(8vt?=epJ^n%8WZrZ|rj4P%)_#^{?CEOgpf&OVVIWGe3KdV02|31!q<2-SSf7D zI(q@<>HPv{-?~Jw?_NBdO9}~!3O(c+cd+t#Es2DQ^vtPj=%T~vY{7LMqSc&Cy&s*7 zkO^d}t_Qttf07Z|@qqeIT+>$Z{|3*)(j_MFo5H|=AZyR*g^yk)%hdfYc-He7zypq# zHj|~>GJZ6#eRyN8{WoP%+pV1{UqZG#M!F&6AuWj&cNYUl%JY?^THbj63U8Zpwq zl9lI$)A&m$Ogt~Es0Av^-2u5x%u-F4ek$*{Ei51bV^@}j0FcMrm*4_2TcmVLk?_Dxc6h(~q z>;>3N-8xFNCx#8)1&_2Ki~EHpvPK)MdgJopfe39`FQU>%2XTN}h$xPJQY%!ek9!BA zzR0k-Z3XZ2XYg@sNsWN-V#_P-J+7l9Fy- zco&6HFhYr6*0efP#+9B;YJtX0z#n3SEiZ5ndzx~@tfwa2pS379Xa06uEC9>1G= z#T(!?XI7x)(~g?}tqvW@L|S`8?$E;%2a_=t8au36v%yPvV;!we!`7T9j0R zotA-ZURwewz|XLquY9&+4Cys7&mrEE23gBBmlfqw&)mS}{V9%O8??K;1x>@(Le zQcgt3Qko)^5I$aE#q0^fMG#P()+Yl@fi+2Z$;dLumnM?$jt~3F3-vm|Gku>Q&4 zfzHxS5tud-1Ta$JzMlX3=swu+yf7Z#j}RcfaJyy`HxKEdA;Ew{ql&9( z-;fw;wjXAP#T!GPr_>$0`ZbZmRR6_nu7)|O4edwLn8)H)vjKc~q{4!Z3G7|52WtKm zbep#{Ip{|KV4{B_-PMU)3gR9pa7HLAM961AX$yH@`AsZu1U<=B(Zl5Iym`71@3XY6;lfxZ%x!a+nV+2l`39*+9fsElxHa8Mn6?eP% z+*9r8U}eCG-+!=~92gC__Dkn5q!jsFENiS<8B}kkPLO=BM+s#g3;myD0pyWMZ^525 z5W4zoE{+~@DMPFr2ZIoYNfDxj8G@7{-(hk}j998XS?A<`0L=(UAg})~38XE#1#_cP zd6GRTtV{{4Fz23uuUsRa?3WA*s`s-MHM~QSZvX&b!W0VZ+2|Gr*yO)k3_wZKI9Gt& zT8Ta=AFHhl0?4lgxTSZ+}3J4*{a9xUnK80fprU~L$#Gt@G$zRE`-M|x=gd!MrXmwPXbxMp!t)gGt5y>L^`obkMn~lb3V9SxLIRfH1lTY54H^#^5Ro&3-rv z2gU&|gZ3nNcQaG$^0xL$FVSlFZbI?Zd8OYWiZ}7!Xdou1Ku5lxW^BK2+wUmVg0c*d z8$Q~w+0EE4Qaa^n3k=cjv+Ib07%`{tExM7SWgOH9MN&EEC!kV-=+jX0S2TaiKKrcE ze-x7$dDY!5;&7dSgq-{0LdvP72X#h;7xt zkJIiL$XPDDEb|7uARQTBpt^Tf4xNubqylJXyGvg_D3HuKAASndUY25<_)3)0 zrLMc>GShOTAO$QL97%pHvFR^hGR$(7k#@B!O>}T<%t`igaC7YCX?D(hicR|dHKzcP zZvwfd7+07+^BE$z9WDbsOM%3C58~{`+e4`@do}|>FsM);t?||C9_0BJ7umDvjP|qv zy5loh@lG-HAEJj10TA;+D4`r53{bEs?cEolMXZ^vRf;{V6Ju8&S#!xCIq|~^a1;5k zM!kLSE8}|7Up~2$!xjJHc-S!gw;iiY;BmfS(%!9<6gsHNv(HKJifN zbW8V1LhP~~Rkj~h=pboA*OJ$gLz0!Ie8%%5^i;J#?QI1jYkTQ*UZEG!IZJJCb)hOi ze3}MEq7+yDC4iMRoPs;9J?-a7iu6a8-v#thJ@mivuRD`r*m=s`O=>c8J{H4jyet2UzA30vn(80{lho?}F z{?19INJ_=L$D*cMwA>y1%wdsnz&;J8a_8MewzlF(iD+~zWgHzT&tFlsRaXqX8alf* z_t$B<_YG>J_%sm>fakB+{a2Q13}oC;yx{E(C7Kw%SJ_&AB=rLv7!*OX#=8+N}WeNeemDK;mg>j&vtBEl0Jl{-Q&!W6*fztc_G zf@`RAZm7$ASypnHOy{$~M)Kidr$Fbg;muIxkdGr~z2*gHnb0HRrycaOm|`mmQfb^% zHogWglgt}(L8Ed8s`c;m_G*<2P9L$b?}$@=TcMR{j9W z#~Jz6Lt8O#;rRnlBz@ZBaO+Mc(E_L*(@k~BlNw4{If6jS>gd|NE$g=|vhTsm-y}d| z{#L%e4q^FJHa*_X+iWpQq4SE%L6!o#G1e``RZ-2ob~)#p59i-v88U@` zEq+{>6rzA>bt)k}Z#}qk0hnW^AF&<{=g_cV{8aDIDK`D^=xRm7!oFt)K9D=h!qt5; zp4~PIEAW)H+~au%U-o&Fe@$%o+S^mlKpLU39_Z^EBomg!A{XQl4bs;wNYOSx(PCsq z%a5eX4fE(1#w$P>SE$WpD@^dJ4!7oO1}2d4o?c&?#;?6asbiaZTqNR2FS%jY&bFJL z%Q(ujEau;Py0PWk1YiE$lb*W(EEhZhBP#mhM&|Zd`k-aNpRt6d*_rzxNaKL6IUl$C zi@L!LDRNsh&7mYgb3NUxH!#!em`!@EFOB2Zo_-s7UAkAogPz*{h%6$_VlVMi?uqw$ z#^B+>c++iSM`a0LJpAfLBH;?i%Hq6$%&4c{v#+etf(u`A%Hqd_Rn7Cl$b8GJ%+b}a zi+$3?HokKA2=8xN#&0GP@Ts=@>-lTy;P`k8O_5dNDmmOD&vEEVc5UCZoc_M=f%N4% zP!XOD_t3kOe&YpBFc=D~CLkYKKS-%p(Y?y)I$cBPyb^@EeVNeGo}^fO!QJBIOu%r$ zHTYa`25OC@H~ku1Z%CeejUMgT9jy7@7+t+Po^0BpydqM#dhLb*-@aVtO>`MMlU)f-=vcaT4Galo3d=;36)Yza|6=qwidD9>Ynn8E)?@;BTsqRy^esMd^cU?NaV=X-IWj=q$7Y$HHsLHV3Qe7G_}*{L3!0_H zNQr`WcgO%#hbF2EyMBn(xaH6szx_DF!LQ>|(Y#IqiF?qkQic3pwq4U=1-r4)@5t}O z=<4Q)4x2-vCvHV$lT3lb1`!9cJ~sH2Md{#crwx@$72Ml5pXvDzQxJ_(sZ)E%vkb0> zpMrs(3c$Hox7c}f;JZ`8yh=#3Lqq8PaXR(4%yi6dw^MKbpZ2bVEy;8Z)7V&ZB`H&> zX=+4nm6l_OjkXP@R%)YWuDDhzm`e*}A!^Ma?q-%JHzq3_HO16o#|ZXADrtN{s8ayJokIw_wzjO`+YHz5mcjILhW@l_IFE+hSRwAw1#;w zCp25M1$^D$pg^tmQ!b`{0q&*HX5>B(?NJ?Qzz^hmBKWw@p7F54jq4j!57a5f7-ZJ+ zOBpZZAU5F3WD3qKW`sWOwbta}o2QV_<@-*%4>3NQAN#wg2~_f+$KNFprvxrM}4CbM+zTpMuNRC_kHQ0t`CfLBQvNNBS)=R(_)xuaV-}eSfkkQ zCDTi)oibjonCV?>b+G4jNa(Q$9kYnnXpTBM()$ozJiJF)so_3glhGRTSXS-zZmN$~ z(>xWy=OfRBs64*BriUZ&2c9T@6xkthw%1e%x7hX?e)ndwk&m(53@-z`YuKqS^_%_V z)yRHA6f~!N666^Z+~MjYGjrp$;26L9cWV%srXAc(|Phe|GnBaaKa@CA6@#%zN{KD6+m=<0y|ciZIe3qnf$X!<}~>I4_ouL)b{*Y(ma6 z*CNj(8|+V@bm}bJyR)IljTOv!KlGq-A@hpaW2}7n4m}&Qcj6PR-G<0#0f9Ya1B?&2 zMRWI8zfkNtE2euX^wt=vC|#QKbD=(0 zh0wYM(SfpRw$PE*SE2&8Uoeul;Pt?aq__Gyx{egt_;ox;gY%reG#%Z=+I}KA{czY? zCQ99@ES;$5WJ^f)Q;;ESQ)xHTK>Oog-6?KSVOEx*YrlfI>PgjY%Gr^D{rgy8sXTuI zc7SE&`97U$fG@&`wlT72(|yeGYW0TCjtK!2N>}Y4M)eo4b&om%I$!4FBaE<>r%7jp zOE%4^ts|+Vh{%pP4r_K7ge^axa5wU|L$bo|6pBpU@l*fUrp>G8Lnb=nE{53l9h?YE z_%EW)DT2Ges)Nzkho}upQ9;Gzx8ERHid||bkf2+0484K;U(3+So|78pr#Y|7Vs$7K zofK1nuAnG;*4@GgVh$=Uh#sm&=Bceopsvsi1UxGxo6V%rSZY$T+rk@6h%KnB2B3xC z;k=*0U3$}~JMk~9*<^<2!N$wT$7ZP(!*Os>H-Pxnv|39#| z9ZEa(%;K6WJ+{J2w%PqC&bf{TKN=x)jKgjN=0n~~f1hv28&-@TdkwpJR8@^56AKdV z7VP<6Aw~fRc{yclvABM>|MlZm#0yp#M?$|*qtxz&77mVUmy?P${gmD7<@j{p!%a+^uww*9J71XNU!*jK@>vpG?LSQ_rOAJqEX!SaI%r~)6>(@#=6R(#A#SnC6qnER984<;esyMt#R){n zSN^c3zpsQNuL;GZ=Y7a9@t$9$oZwQWaV_ z4P2bV$VN>DNd$aCcDiZn?4o`9c7FhuttbsMdD5zaItUfV^K+8M{*VCqbuvyWskMgTM9^)Lnx+#_iwFMQ+_dQ+!nA*Kd0Ywap7p=TZ;285^`AzQ-Lz2zre0ZsblW8C48X6166qn@NO`noWhgJCrF+sLk((-d(c^HwIMOWYRoykiTvUOz;tMdrUQo+YDD_ts_u{mD-^-qd zTHjOdWM`BG|MFUe^${c5(&A0bB<{Xst2>nYqdOv`d4--OGVSNQH(rhp_{tfyunua) zVijdf0ux9UPU299JATK0 zML@#bNRB(t(T&cNVCK@P2k?m)k){sK#rbm{Srcjrha=IXWJv`k5%?k<;{{~6YnG^Q zpdQ*^&DO1-YqBYo*;;Y7I&!x*&9_QFoT+6HxifQ`nc#W=c7g)qTGE)dkEsFnnxjYG4m?gI`-sdw{5&Z?hwl4jgkj$w@XknaDa9^6o?~{M~ zoGoqMC2$!9_yI7ITcJ(dVzi+qcv|3pFGy%^nE6)bT7`^hDXt8(ezF0g1*$*a8R#5y z#d*B3?_KMIK7k`YF~9j;o64CaN+QbKqXk_RPM9u9E?4L$$Yu147!aPo`}Tyo+AnV} ziybi&=+h6i7%zY4FMrg#0}bEuR1Px@qg^wky>2a@kCgNuDOIJ-i>X?B1 zh6B4o_*h=ZS-$%&LhlIb6w$%T63+LNtz~a0@fsg}R9{ZcBCpypSX^DejjAkX!V?%x z?%~@tML0SEnzKJNfkrGw>Fy0c4eRJ<-$pr zAfNKks+kw`XWEZtE-gbtO;uQ1d-ip^zO*97DfY(U1BoNA|@pUj^FN%C0b* zPct}-KiGbrP#nQ)>O$LmwzN4DOK2yC8@ zrHEb{2yRE*ki1kkUOiUgP+RSFt@m(%g8bn1KR-zPBLuiNzpnD6)$z0cgya7x0xY3y Yovp-$*53VzEMNA?6Ao7NpL}oq8-ctDAOHXW literal 0 HcmV?d00001 diff --git a/docs/_static/js/sync-bootstrap-theme.js b/docs/_static/js/sync-bootstrap-theme.js new file mode 100644 index 0000000..131f2c0 --- /dev/null +++ b/docs/_static/js/sync-bootstrap-theme.js @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 calendaring-jmap contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +// pydata-sphinx-theme toggles light/dark via the html element's data-theme +// attribute; the Bootstrap CSS vendored inside the theme's own stylesheet +// keys its dark-mode variable block off data-bs-theme instead, which this +// theme never sets. Left alone, every Bootstrap component (dropdown menus, +// tooltips, and anything else using --bs-* variables instead of the +// theme's own --pst-color-* ones) stays stuck in light colors even when +// the page is in dark mode. Mirroring the attribute is the only fix that +// covers every such component, including ones added later, instead of +// hand-overriding each affected --bs-* variable one at a time. +(function () { + var html = document.documentElement; + + function sync() { + var mode = html.getAttribute("data-theme"); + if (mode) { + html.setAttribute("data-bs-theme", mode); + } + } + + sync(); + new MutationObserver(sync).observe(html, { + attributes: true, + attributeFilter: ["data-theme"], + }); +})(); diff --git a/CHANGES.rst b/docs/changelog.rst similarity index 88% rename from CHANGES.rst rename to docs/changelog.rst index ba4e38b..7b78b48 100644 --- a/CHANGES.rst +++ b/docs/changelog.rst @@ -5,6 +5,11 @@ Change log ========== +.. meta:: + :description: Every release of calendaring-jmap, newest first, with what changed in each one. + +Every calendaring-jmap release, newest first: new features, bug fixes, breaking changes, and documentation updates, each with the pull request that shipped it. + .. py:currentmodule:: calendaring_jmap .. Do *NOT* add new change log entries to this file. diff --git a/docs/conf.py b/docs/conf.py index baef13a..8154555 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -11,7 +11,6 @@ copyright = f"{this_year}, calendaring-jmap contributors" # noqa: A001 author = "calendaring-jmap contributors" -# Extensions extensions = [ "notfound.extension", "sphinx.ext.autodoc", @@ -21,12 +20,26 @@ "sphinx_copybutton", "sphinx_design", "sphinx_issues", + "sphinx_last_updated_by_git", + "sphinxext.opengraph", ] +html_baseurl = "https://calendaring-jmap.readthedocs.io/" + +ogp_site_url = html_baseurl +ogp_site_name = "calendaring-jmap" +ogp_description_length = 200 +ogp_type = "website" +ogp_social_cards = { + "image": "_static/img/pycal-icon.png", + "image_mini": "_static/img/pycal-icon.png", + "line_color": "#0f766e", + "background_color": "#f8f7f4", +} + # sphinx_issues configuration: enables :issue:`N`, :pr:`N`, :user:`name` roles issues_github_path = "pycalendar/calendaring-jmap" -# Theme configuration html_theme = "pydata_sphinx_theme" html_theme_options = { "icon_links": [ @@ -45,17 +58,19 @@ "attributes": {"target": "_blank", "rel": "noopener me"}, }, ], - "footer_start": ["nlnet", "copyright"], - "footer_end": ["theme-version", "sphinx-version"], + "footer_start": ["copyright"], + "footer_end": ["nlnet"], "logo": {"text": "calendaring-jmap"}, - "use_edit_page_button": True, "show_toc_level": 2, "navbar_align": "content", "show_nav_level": 1, "navigation_with_keys": True, "collapse_navigation": False, "search_bar_text": "Search documentation", + "secondary_sidebar_items": ["page-toc"], + "article_footer_items": ["last-updated"], } +html_last_updated_fmt = "%Y-%m-%d" html_context = { "github_user": "pycalendar", @@ -66,10 +81,23 @@ templates_path = ["_templates"] html_static_path = ["_static"] -# Custom fa-custom/fa-pypi icon used in icon_links above -html_js_files = [("js/custom-icons.js", {"defer": "defer"})] +# Custom fa-custom/fa-pypi icon used in icon_links above; sync-bootstrap-theme +# mirrors data-theme onto data-bs-theme so Bootstrap's own dark-mode variables +# (dropdown menus, tooltips) activate instead of staying stuck in light mode. +html_js_files = [ + ("js/custom-icons.js", {"defer": "defer"}), + ("js/sync-bootstrap-theme.js", {"defer": "defer"}), +] +# Loaded as a separate rather than @import inside pycal-theme.css: +# an @import must be the first rule in a stylesheet, and if that external +# request is ever blocked or slow (an ad blocker, a privacy extension, a +# flaky network), browsers can fail to parse the rest of the file along +# with it, taking every color override down with the font. +html_css_files = [ + "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Playfair+Display:wght@700&family=Cascadia+Code&display=swap", + "css/pycal-theme.css", +] -# notfound.extension configuration notfound_template = "404.html" # Defaults to READTHEDOCS_CANONICAL_URL's path on RTD; outside RTD that env # var isn't set and the extension falls back to a hardcoded "/en/latest/", @@ -78,13 +106,12 @@ if not os.environ.get("READTHEDOCS"): notfound_urls_prefix = "/" -# Intersphinx mapping intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "icalendar": ("https://icalendar.readthedocs.io/en/latest/", None), + "niquests": ("https://niquests.readthedocs.io/en/latest/", None), } -# Napoleon settings (Google-style docstrings) napoleon_google_docstring = True napoleon_numpy_docstring = False napoleon_include_init_with_doc = True @@ -93,7 +120,6 @@ # introspection and produce "duplicate object description" warnings. napoleon_use_ivar = True -# Autodoc settings autodoc_default_options = { "members": True, "undoc-members": True, diff --git a/docs/contribute.rst b/docs/contribute.rst index 904a52c..98bccf8 100644 --- a/docs/contribute.rst +++ b/docs/contribute.rst @@ -36,6 +36,8 @@ Integration tests, against Cyrus IMAP and Stalwart via Docker: tests/docker/stalwart/start.sh pytest src/calendaring_jmap/tests/test_jmap_integration.py +Add a test to ``test_jmap_unit.py`` for anything that doesn't need a real server: request-building, response parsing, error handling, conversion logic. Mock HTTP with ``unittest.mock``, following ``_MockedClientMixin``'s pattern. Add a test to ``test_jmap_integration.py`` only for behavior that depends on how a real server actually responds, such as a server-specific quirk or a capability check; each test class there skips automatically when its server isn't reachable, so a missing Docker container doesn't fail CI, it just skips those tests. + Previewing docs =============== @@ -46,6 +48,18 @@ Previewing docs Opens a local server that rebuilds and reloads the browser tab automatically as you edit files under ``docs/``. +.. _vale-check: + +Checking docs prose with Vale +============================= + +.. code-block:: bash + + vale sync + vale docs/ + +CI runs this the same way, as a warn-only check (a failure doesn't block a PR yet): it catches a real subset of :ref:`writing-documentation`'s rules mechanically, but it isn't a substitute for reading your own diff. + .. _artificial-intelligence-policy: Artificial intelligence policy @@ -56,15 +70,15 @@ calendaring-jmap follows the Python Calendaring Ecosystem's `AI policy `` is one of: ``breaking``, ``removal``, ``feature``, ``bugfix``, ``documentation``, ``deps``, ``internal``, ``chore``, ``security``. -Write a short, user-facing description of the change inside the file: state what changed and, if it's a fix, what happened before. The issue link is generated automatically from the filename's number, so don't add one yourself. If your PR isn't tied to an issue, prefix the filename with ``+`` instead of a number (e.g. ``+conversion-validation.bugfix.rst``); towncrier accepts orphan fragments this way and just omits the issue link. Fragments are collected into ``CHANGES.rst`` at release time. Don't edit that file directly. +Write a short, user-facing description of the change inside the file: state what changed and, if it's a fix, what happened before. The issue link is generated automatically from the filename's number, so don't add one yourself. If your PR isn't tied to an issue, prefix the filename with ``+`` instead of a number (for example ``+conversion-validation.bugfix.rst``); towncrier accepts orphan fragments this way and just omits the issue link. Fragments are collected into ``docs/changelog.rst`` at release time. Don't edit that file directly. See :doc:`release` for the maintainer-only steps that actually cut a release. -Keep each fragment to one or two sentences. State the change; don't explain how it works internally, why it was needed, or how it was found, that belongs in the PR description, not the changelog. If your PR touches more than one distinct behavior (two separate bugs, or a feature plus an unrelated parameter addition), write one fragment per behavior (``11.feature.1.rst``, ``11.feature.2.rst``, and so on) instead of one fragment covering all of them. See any ``bugfix`` fragment in ``news/`` for the length and tone to aim for. +Keep each fragment to one or two sentences. State the change; don't explain how it works internally, why it was needed, or how it was found, that belongs in the PR description, not the changelog. If your PR touches more than one distinct behavior (two separate bugs, or a feature plus an unrelated parameter addition), write one fragment per behavior (``11.feature.1.rst``, ``11.feature.2.rst``, and further numbered fragments as needed) instead of one fragment covering all of them. See any ``bugfix`` fragment in ``news/`` for the length and tone to aim for. If you used AI to help write the change, briefly disclose it in the fragment, per the :ref:`artificial-intelligence-policy`. @@ -100,6 +114,9 @@ Code style ``ruff`` and ``mypy`` catch syntax and typing issues; they don't catch everything below, so review your own diff for it before opening a PR. +Sync/async parity + A new or changed public method on :class:`~calendaring_jmap.client.JMAPClient` needs the matching method on :class:`~calendaring_jmap.async_client.AsyncJMAPClient`, with the same signature and behavior, differing only in that it's a coroutine. See :doc:`explanation/design` for why this parity exists and how the shared base class keeps the two clients from duplicating their parsing and request-building logic. + No duplication If you're about to write a function, block, or test fixture that's the same shape as one that already exists elsewhere (even with different variable names), extract a shared helper instead. This applies to tests as much as source: see ``_MockedClientMixin`` and the module-level ``_set_response``/``_get_response`` helpers in ``test_jmap_unit.py`` for the established pattern. @@ -109,16 +126,57 @@ No hardcoding RFC citations In docstrings (not comments, not test files, see below), cite a real, finalized RFC with Sphinx's ``:rfc:`` role, and always merge the section number into the role itself: ``:rfc:`8984#section-5.1.2``, not ``:rfc:`8984` section 5.1.2``. The latter renders as two disconnected pieces, a link to the RFC's front page and plain text next to it, so a reader has to manually find the section after clicking through. Verify the section number against the actual RFC text before citing it; don't guess from a related section or trust an existing comment's number without checking. - A spec that has no RFC number yet (``draft-ietf-jmap-calendars`` as of this writing) can't use ``:rfc:``, since the role links to ``rfc-editor.org/rfc/rfcNNNN`` and there's no such page yet. Cite it as plain text (``draft-ietf-jmap-calendars section 2.2``) until it's assigned a number, then convert it. + A spec that has no RFC number yet (``draft-ietf-jmap-calendars`` as of this writing) can't use ``:rfc:``, since the role links to ``datatracker.ietf.org/doc/html/rfcNNNN.html`` and there's no such page yet. Cite it as plain text (``draft-ietf-jmap-calendars section 2.2``) until it's assigned a number, then convert it. ``:rfc:`` only renders correctly in docstrings that Sphinx's ``automodule``/autodoc actually pulls in (check ``docs/reference/*.rst`` for which modules that is; private, underscore-prefixed modules aren't autodoc'd, so a role there is only for source readers, not the built docs). Plain ``#``-comments are never RST-parsed, and neither are docstrings inside ``tests/``, so use plain "RFC NNNN section N.N" text in both, not the role. +.. _prose-docstrings-comments: + Prose in docstrings and comments - No em dashes and no spaced hyphens (``word - word``) as a substitute for a colon, comma, or period; pick the punctuation the sentence actually needs. This reads as a tell for AI-generated text that wasn't edited, and the project wants human-edited prose throughout, disclosed AI use notwithstanding (see the :ref:`artificial-intelligence-policy`). Same for the Unicode arrow ``→`` used as shorthand for "produces" or "maps to": write out "to"/"produces"/"maps to", or use the ASCII ``->`` inside a code example where alignment matters. + Write plainly and specifically: say what's actually true about this code, not a general description that could apply to any project. Pick the punctuation a sentence actually needs instead of leaning on an em dash or a spaced hyphen (``word - word``) as a stand-in for a colon, comma, or period; the same goes for the Unicode arrow ``→`` used as shorthand for "produces" or "maps to," which reads clearer written out, or as ASCII ``->`` inside a code example where alignment matters. + + A few words read as filler more often than not and are usually worth cutting: delve, leverage, utilize (say "use"), robust, seamless, pivotal, cutting-edge, game-changer. If a sentence is true and specific without one of these, it almost never needs it back in. Dead code Before adding a new helper, check whether an existing one already does the job. If your change makes a function unreachable, remove it in the same PR rather than leaving it for a future cleanup pass; check the milestone tracker first in case a near-term feature already claims it. +.. _writing-documentation: + +Writing documentation +===================== + +This section covers ``docs/*.rst`` page prose: how-to guides, tutorials, reference pages, and explanation pages. For docstring and comment conventions, see :ref:`code-style` above; this section cross-references those rules rather than restating them. + +Choosing a Diataxis category + calendaring-jmap's docs are organized into four categories: tutorials, how-to guides, reference, and explanation. This split comes from the `Diátaxis framework `_. Django's docs use the same four-way split, under different section names; pytest's docs use nearly these same names. Each category answers a different question, so pick the one matching what the reader actually needs: + + - **Tutorial** (``tutorials/``): a single guided path a newcomer follows start to finish, learning by doing. See :doc:`tutorials/quickstart` for the shape this takes. New content almost never belongs here: a tutorial teaches someone who knows nothing yet, which is a narrow, rarely-needed case, and most new content is really a how-to. + - **How-to guide** (``how-to/``): a terse recipe for someone who already knows the basics and wants to do one specific thing. No "why" content inline: cross-reference :doc:`explanation/design` instead (see ``how-to/errors.rst``'s opening line for the pattern). + - **Reference** (``reference/``): generated, factual lookup material. This is almost entirely autodoc output; hand-written prose here should be limited to a short page intro, not an explanation of design choices. + - **Explanation** (``explanation/``): the why, background, and design rationale, kept separate so how-to pages stay scannable. ``explanation/design.rst``'s existing "Why X" section headings are the pattern to match. + +Write about JMAP, not about other protocols + A reader of these docs is here to learn JMAP, not CalDAV, WebDAV, or anything else. Don't explain a JMAP behavior by contrasting it against how another protocol or library does the same thing, even as a single clarifying aside: state the JMAP fact on its own terms. This applies to docstrings too, not just ``.rst`` pages, since they render into the published reference. + +Prose style for pages + Page prose follows the `Microsoft Writing Style Guide `_: plain language, active voice, second person ("you," not "the user"), sentence-case headings, no exclamation points. Contractions are fine. Avoid Latin abbreviations in prose ("for example," not "e.g."; "that is," not "i.e."). This is a different, more conversational register than :ref:`prose-docstrings-comments`'s rule, which governs terse, factual docstring/comment text; that rule's bans apply here too, unchanged. + + Write each paragraph as one line, not hard-wrapped at a fixed column width. RST collapses whitespace, so wrapping serves no rendering purpose; every existing page in this repo is written this way. + + Avoid "simple," "just," "very," and "easy": a reader who can't complete the task as described finds these words condescending rather than reassuring. Prefer "such as" over "like" when introducing an example, "select" over "check"/"tick" for a UI action, and "verify" over "be sure" when asking the reader to confirm something. + + Spell these terms consistently: GitHub, add-on (not addon), plug-in (not plugin), reST or reStructuredText (not RST, except as the file extension or in a code span like ``.rst``). + + :ref:`vale-check` runs the Microsoft Vale style package plus `signs-of-ai-writing `_ (patterns from Wikipedia's `Signs of AI writing `_ essay) in CI, catching a real subset of these rules mechanically: contractions, passive voice, dash spacing, sentence-case headings, hedging clusters, chatbot phrases, and the AI-typical vocabulary and symbolic-language patterns the essay documents. It doesn't catch everything: the Diataxis category choice and whether a how-to page pushed "why" content where it belongs still need a reviewer's judgment. + +Cross-referencing and code examples + Link between pages with ``:doc:`` and an explicit relative path (``:doc:`../explanation/design```). Reference a class or method with ``:class:``/``:meth:`` and a leading ``~`` for a short display name (``:class:`~calendaring_jmap.objects.calendar.JMAPCalendar```). Cite an RFC the same way :ref:`code-style`'s "RFC citations" rule describes for docstrings, merging the section into the role. + + Use ``.. code-block:: python`` or ``.. code-block:: bash``, with a blank line before and after the directive and the indented block. A tutorial ends with an explicit "Next steps" section linking forward to relevant how-to/explanation pages, matching ``tutorials/quickstart.rst``. + +Title underlines + RST title underlines must match the title's exact character width. Sphinx only warns about this for a page title's overline/underline pair, not for an ordinary section heading's underline alone, so a mismatched section underline builds silently; count it yourself rather than assume a clean build means it's correct. + License ======= diff --git a/docs/explanation/design.rst b/docs/explanation/design.rst index d6ed0b5..849f8b1 100644 --- a/docs/explanation/design.rst +++ b/docs/explanation/design.rst @@ -8,7 +8,7 @@ Design Why JMAP identity is opaque =========================== -JMAP identifies calendars, events, and tasks by opaque server-assigned IDs, not URLs. This is a real difference from CalDAV, which is URL-addressed: a CalDAV client's identity for an object is the URL it lives at, and moving or renaming a resource can change that URL. A JMAP object's ID stays the same for its lifetime regardless of where the server chooses to store it. +JMAP identifies calendars, events, and tasks by opaque server-assigned IDs, not URLs. An object's ID stays the same for its lifetime regardless of where or how the server chooses to store it. This shapes the client's API: :meth:`~calendaring_jmap.client.JMAPClient.create_event` and similar methods return an ID string, not a URL, and every subsequent lookup, update, or delete uses that ID. There's no separate "fetch the URL, then act on it" step. @@ -17,9 +17,50 @@ Why there's a sync and an async client :class:`~calendaring_jmap.async_client.AsyncJMAPClient` mirrors every public method of :class:`~calendaring_jmap.client.JMAPClient` as a coroutine, rather than the library offering only one and asking callers to wrap it. Both share their parsing and request-building logic through a common base class (``_JMAPClientBase``), so the two clients don't duplicate that logic, only the transport call itself differs (blocking request vs. ``await``). -:class:`~calendaring_jmap.objects.calendar.JMAPCalendar` and :class:`~calendaring_jmap.objects.calendar_object.JMAPCalendarObject` need to support both: the same calendar object type is returned whether it came from the sync or the async client, and its methods (``search``, ``add_event``, and similar) need to return a plain value in the sync case and a coroutine in the async case. Python's type system can't narrow a return type based on a runtime flag, so calendaring-jmap uses a generic type parameter on ``JMAPCalendar`` purely for static typing: it lets type checkers give a correctly-typed result depending on whether the calendar came from :meth:`~calendaring_jmap.client.JMAPClient.get_calendars` or :meth:`~calendaring_jmap.async_client.AsyncJMAPClient.get_calendars`, without changing anything at runtime. +:class:`~calendaring_jmap.objects.calendar.JMAPCalendar` and :class:`~calendaring_jmap.objects.calendar_object.JMAPCalendarObject` need to support both: the same calendar object type is returned whether it came from the sync or the async client, and its methods (``search``, ``add_event``, and similar) need to return a plain value in the sync case and a coroutine in the async case. Python's type system can't narrow a return type based on a runtime flag, so calendaring-jmap uses a generic type parameter on :class:`~calendaring_jmap.objects.calendar.JMAPCalendar` purely for static typing: it lets type checkers give a correctly-typed result depending on whether the calendar came from :meth:`~calendaring_jmap.client.JMAPClient.get_calendars` or :meth:`~calendaring_jmap.async_client.AsyncJMAPClient.get_calendars`, without changing anything at runtime. Why there's a fixup step ======================== Servers can return iCalendar data that doesn't fully comply with :rfc:`5545`: a missing ``DTSTAMP``, a duplicated line, a date field out of the valid range. calendaring-jmap corrects a small, fixed set of known issues before handing iCalendar strings back to callers, rather than surfacing a parse error for data a real server actually sent. See :doc:`../security` for why these corrections are implemented as simple, non-backtracking regular expressions. + +Why CONFERENCE and VCONFERENCE correlate by URI +=============================================== + +A CONFERENCE property (:rfc:`7986`) and a VCONFERENCE component both describe the same video or phone link, but the spec gives them no shared identifier: iCalendar has no concept of one property pointing at a specific sibling component by ID. The conversion layer matches them by comparing the CONFERENCE property's own value against the VCONFERENCE component's ``URI`` property, string for string. A VCONFERENCE whose URI matches no CONFERENCE property on the same component converts to nothing: there's no VirtualLocation for it to attach its description to. + +Within a matched VCONFERENCE, ``STYLED-DESCRIPTION`` (:rfc:`9073`) is preferred over plain ``DESCRIPTION`` when both are present, since only ``STYLED-DESCRIPTION`` carries a content type. The draft's own ``DERIVED`` parameter decides which one actually describes the conference and which one a server generated from the other: a non-derived ``STYLED-DESCRIPTION`` is treated as the one carrying real content, even when a ``DESCRIPTION`` also happens to be present. + +Why recurrence override patches compare by value, not by key +============================================================ + +A recurrence override patch only needs to carry what actually changed on that one occurrence, not the whole event again. For most properties this is a direct comparison: a changed ``title`` string either matches the master's or it doesn't. The map-shaped properties (``participants``, ``locations``, ``alerts``, ``virtualLocations``) don't work that way, since each entry gets a fresh, randomly generated key every time the conversion layer builds one, so the same unchanged location on the master and an override child never share a key even when nothing about it actually changed. Comparing these maps by their sorted canonical content instead of by key-for-key equality is what makes the override diff correct: a location that's genuinely identical on both sides is correctly left out of the patch, and a location that's genuinely different is correctly included. + +Why participants carry calendarAddress, not sendTo +================================================== + +An ``ORGANIZER``/``ATTENDEE`` value is a URI (:rfc:`5545#section-3.3.3`). It doesn't have to use the ``mailto:`` scheme; ``sip:alice@example.com`` is valid too. JSCalendar's Participant ``email`` property is narrower: :rfc:`8984#section-4.4.6` defines it as an :rfc:`5322#section-3.4.1` addr-spec, not an arbitrary URI. So a non-mailto address converts to ``calendarAddress`` as-is, and ``email`` is left unset rather than filled with a value that isn't actually an email address. + +JSCalendar's own Participant object defines ``sendTo`` (a map of delivery method to URI), not ``calendarAddress``; the latter belongs to JMAP Calendars' ParticipantIdentity/Principal objects instead. Cyrus doesn't accept ``sendTo`` at all: ``CalendarEvent/set`` rejects it outright with ``invalidProperties`` on both create and update, and requires ``calendarAddress`` in its place. Tested against a running Cyrus container: ``sendTo`` alone is rejected, ``calendarAddress`` alone is accepted, and having both still gets rejected for ``sendTo``. calendaring-jmap sets ``calendarAddress`` to ``sendTo``'s would-be ``imip`` value, its natural equivalent per ParticipantIdentity's own definition, and omits ``sendTo`` entirely. Stalwart accepts the ``calendarAddress``-only form too. + +Why an Alert's trigger is an object, not a string +================================================= + +A JSCalendar Alert's ``trigger`` is an OffsetTrigger or AbsoluteTrigger object (:rfc:`8984#section-4.5.2`), not a bare duration or timestamp string. A relative ``VALARM`` ``TRIGGER`` becomes ``{"@type": "OffsetTrigger", "offset": "-PT15M", "relativeTo": "start"}``; an absolute one becomes ``{"@type": "AbsoluteTrigger", "when": "..."}``. ``TRIGGER`` is mandatory on a ``VALARM`` per :rfc:`5545#section-3.6.6` and :rfc:`8984#section-4.5.2` alike, but not every producer enforces it. Rather than failing the whole event's conversion over one malformed sub-item, calendaring-jmap skips just that one alarm, with a warning. + +Why Link conversion is narrower than the draft's full mapping +============================================================= + +draft-ietf-calext-jscalendar-icalendar section 3.4 defines a full Link/ATTACH/IMAGE/LINK mapping. calendaring-jmap implements only the attachment case its own :meth:`~calendaring_jmap.client.JMAPClient.attach_to_event`/:meth:`~calendaring_jmap.client.JMAPClient.get_event_attachments` need. A URI-form ``ATTACH`` converts to ``href`` as-is. A binary-form ``ATTACH`` (:rfc:`5545#section-3.8.1.1`'s inline ``ENCODING=BASE64;VALUE=BINARY`` form) converts to a ``data:`` URL (:rfc:`2397`) instead, with ``FMTTYPE`` becoming both the data URL's media type and ``contentType``. ``rel`` is always ``"enclosure"``, since an iCalendar ``ATTACH``, unlike JSCalendar's general Link object, has no notion of a link that isn't an attachment; every ``ATTACH`` converts as one, and the reverse only happens for a Link whose ``rel`` is ``"enclosure"``. + +A plain ``URL`` property has no defined relationship to the links JSCalendar names (:rfc:`5545#section-3.8.4.6` versus :rfc:`8984#section-4.2.7`'s enclosure/describedby/icon). It converts to a Link with no ``rel`` set, matching the Link object's own optional ``rel`` (:rfc:`8984#section-1.4.11`), and only a Link with no ``rel`` at all converts back to ``URL``. + +Why recurrence rule UNTIL sometimes converts time zones +======================================================= + +JSCalendar's ``until`` on a RecurrenceRule is a LocalDateTime in the event's own time zone. iCalendar's ``UNTIL`` has a stricter rule: :rfc:`5545#section-3.3.10` requires it to be UTC whenever ``DTSTART`` is a TZID or UTC date-time. So converting a non-UTC ``until`` back to iCalendar resolves it to UTC first, rather than passing the local value through with the wrong implied zone. + +Why the error hierarchy is two-tiered +===================================== + +:class:`~calendaring_jmap.error.JMAPBaseError` carries only a URL and a reason string; :class:`~calendaring_jmap.error.JMAPError` subclasses it and adds :rfc:`8620`'s ``error_type`` string. The split exists because ``JMAPBaseError`` can optionally subclass an installed ``caldav`` package's own ``DAVError``, giving code that catches ``DAVError`` around CalDAV calls a way to catch these errors too; that subclassing has nothing to do with JMAP's own error-type model, so it lives on the narrower base class, not on ``JMAPError`` itself. Every other error class (:class:`~calendaring_jmap.error.JMAPCapabilityError`, :class:`~calendaring_jmap.error.JMAPAuthError`, :class:`~calendaring_jmap.error.JMAPMethodError`) extends ``JMAPError``, since all three represent an actual JMAP-level failure with a real ``error_type``. diff --git a/docs/explanation/index.rst b/docs/explanation/index.rst new file mode 100644 index 0000000..d369a28 --- /dev/null +++ b/docs/explanation/index.rst @@ -0,0 +1,16 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-jmap contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +=========== +Explanation +=========== + +.. meta:: + :description: Background and design rationale for why calendaring-jmap is built the way it is. + +Background and design rationale: why the client is built the way it is, not how to use it. See the how-to guides for task-focused instructions instead. + +.. toctree:: + :maxdepth: 1 + + design diff --git a/docs/how-to/attachments.rst b/docs/how-to/attachments.rst new file mode 100644 index 0000000..ed80969 --- /dev/null +++ b/docs/how-to/attachments.rst @@ -0,0 +1,48 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-jmap contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +===================== +Work with attachments +===================== + +Upload and attach a file +======================== + +.. code-block:: python + + with open("agenda.pdf", "rb") as f: + blob_id = client.upload_attachment(f.read(), content_type="application/pdf") + + client.attach_to_event( + event_id, + blob_id=blob_id, + name="Agenda", + content_type="application/pdf", + ) + +``upload_attachment`` is a raw HTTP upload, not a JMAP method call; it returns a blob ID to pass to ``attach_to_event`` or :meth:`~calendaring_jmap.client.JMAPClient.download_attachment`. + +``attach_to_event`` reads the event's current ``links``, then replaces the whole property with the merged result. There's no concurrency guard: a second concurrent call to this method for the same event can silently overwrite the first. Serialize calls for a given event if that matters to you. + +List an event's attachments +=========================== + +.. code-block:: python + + attachments = client.get_event_attachments(event_id) + for attachment in attachments: + print(attachment.title, attachment.href) + +Download an attachment +====================== + +.. code-block:: python + + data = client.download_attachment(blob_id, content_type="application/pdf", filename="agenda.pdf") + +``content_type`` and ``filename`` only affect the response's own headers; an omitted or wrong value doesn't affect whether the download succeeds. + +Cyrus limitation +================ + +Cyrus accepts an attachment write but doesn't reliably persist the attachment's ``rel: "enclosure"`` marker, so :meth:`~calendaring_jmap.client.JMAPClient.get_event_attachments` may not find an attachment ``attach_to_event`` just wrote. Stalwart is unaffected. diff --git a/docs/how-to/authenticate.rst b/docs/how-to/authenticate.rst index a90257b..3822156 100644 --- a/docs/how-to/authenticate.rst +++ b/docs/how-to/authenticate.rst @@ -5,7 +5,22 @@ Authenticate ============ -:func:`~calendaring_jmap.get_jmap_client` reads configuration from, in order: explicit keyword arguments, environment variables, then a YAML config file. If none of those are set, it returns ``None``. +:func:`~calendaring_jmap.get_jmap_client` reads configuration from explicit keyword arguments, environment variables, then a YAML config file, checked in that order. If none of the three sources supplies a ``url``, it returns ``None``. + +The first source that supplies a ``url`` wins outright for that call: passing ``url`` explicitly, even with no other arguments, uses only the explicit arguments, and doesn't fall back to environment variables or the config file for ``username``/``password``/``auth_type``/``timeout``. If you mix sources, supply ``url`` from the lowest-priority source you want to use; a source that doesn't supply ``url`` still has its other fields merged in underneath whichever source does. + +Resolve configuration without building a client +=============================================== + +:func:`~calendaring_jmap.get_connection_params` runs the same resolution :func:`~calendaring_jmap.get_jmap_client` does, but returns the resolved dict instead of a client, or ``None`` if nothing was found. Useful if you want to inspect or adjust the result before constructing a client yourself, or you're building your own thin wrapper around this project's config sources (for example, a CLI tool that passes every flag through unconditionally, whether or not the user set it, without wiping out an environment variable for the flags they left off): + +.. code-block:: python + + from calendaring_jmap import get_connection_params + + params = get_connection_params(url=cli_args.url, username=cli_args.username) + if params is None: + raise SystemExit("No JMAP configuration found") Use environment variables or a config file ========================================== @@ -41,7 +56,7 @@ With either in place, no arguments are needed: Use Basic or Bearer auth explicitly =================================== -HTTP Basic auth is used when a ``username`` is supplied alongside a ``password``. Bearer token auth is used when only a ``password`` (token) is given and no username. +Supplying a ``username`` alongside a ``password`` uses HTTP Basic auth. Supplying only a ``password`` (token), with no username, uses Bearer token auth. .. code-block:: python @@ -74,4 +89,19 @@ Pass any ``requests``-compatible auth object directly via the ``auth`` parameter auth=HTTPBasicAuth("alice", "secret"), ) -Unlike CalDAV, JMAP does not use a 401 challenge and retry. Credentials are sent on every request, and a 401 or 403 is a hard :class:`~calendaring_jmap.error.JMAPAuthError`. +JMAP doesn't use a 401 challenge and retry. Credentials are sent on every request, and a 401 or 403 is a hard :class:`~calendaring_jmap.error.JMAPAuthError`. + +Release the connection without a context manager +================================================ + +If you don't use ``with``/``async with``, call :meth:`~calendaring_jmap.client.JMAPClient.close` (or :meth:`~calendaring_jmap.async_client.AsyncJMAPClient.aclose` on the async client) when you're done, to release the underlying HTTP session and its connection pool: + +.. code-block:: python + + client = get_jmap_client() + try: + calendars = client.get_calendars() + finally: + client.close() + +Calling it is optional. Forgetting isn't harmful: the session is recreated automatically on the next request. diff --git a/docs/how-to/calendars.rst b/docs/how-to/calendars.rst index d99c937..b0f422d 100644 --- a/docs/how-to/calendars.rst +++ b/docs/how-to/calendars.rst @@ -1,7 +1,10 @@ .. SPDX-FileCopyrightText: 2026 calendaring-jmap contributors .. SPDX-License-Identifier: AGPL-3.0-or-later -============== +=================== +Work with calendars +=================== + List calendars ============== @@ -12,3 +15,85 @@ List calendars print(cal.id, cal.name, cal.color) Each item is a :class:`~calendaring_jmap.objects.calendar.JMAPCalendar`; see :doc:`../reference/objects` for its fields. + +Pass ``account_id`` to list calendars on another account that's been shared with you: + +.. code-block:: python + + calendars = client.get_calendars(account_id=other_account_id) + +Create a calendar +================= + +.. code-block:: python + + calendar_id = client.create_calendar(name="Team events", color="#2E86AB") + +``name`` is required. ``color`` and ``timezone`` are optional. + +Update a calendar +================= + +.. code-block:: python + + client.update_calendar(calendar_id, name="Team events (archived)") + +Only the arguments you pass change; the rest are left alone. ``name``, ``color``, and ``timeZone`` are per-user: updating them as the calendar's owner changes what every sharee sees, until a sharee sets their own override on top. + +Delete a calendar +================= + +.. code-block:: python + + client.delete_calendar(calendar_id) + +Deleting a calendar that still has events raises :class:`~calendaring_jmap.error.JMAPMethodError` with ``error_type`` ``"calendarHasEvent"``, rather than deleting anything, unless you pass ``on_destroy_remove_events=True``: + +.. code-block:: python + + client.delete_calendar(calendar_id, on_destroy_remove_events=True) + +Share a calendar +================ + +.. code-block:: python + + client.share_calendar( + calendar_id, + account_id=principal_id, + rights={"mayReadItems": True, "mayWriteItems": True}, + ) + +``account_id`` must already be a resolved JMAP Principal ID, not an email address; this client has no ``Principal/query`` support yet, so resolving an email to a Principal ID is left to you. ``rights`` replaces that account's entire rights entry on this calendar; it isn't merged with rights the account already had, so granting one additional right still needs the full rights dict. Sharing requires you to already hold the ``mayShare`` right yourself, and you can't grant a right you don't hold. + +Read back a calendar's current sharing configuration from its ``share_with`` field, populated when you have the ``mayShare`` right yourself and ``None`` otherwise: + +.. code-block:: python + + cal = client.get_calendars()[0] + print(cal.share_with) + # {"principal-abc": {"mayReadItems": True, "mayWriteItems": False}} + +Set default alerts +================== + +New events created on a calendar can inherit default alerts, one set for timed events and one for all-day events: + +.. code-block:: python + + client.set_default_alerts( + calendar_id, + alerts_with_time={"a1": {"@type": "Alert", "trigger": {"@type": "OffsetTrigger", "offset": "-PT15M"}}}, + alerts_without_time={}, + ) + +Pass ``None`` (the default) for either argument to leave it unchanged; pass ``{}`` to clear it. + +Subscriptions +============= + +.. code-block:: python + + subscribed = client.get_calendar_subscriptions() + +Equivalent to filtering :meth:`~calendaring_jmap.client.JMAPClient.get_calendars` to ``is_subscribed``; JMAP Calendars has no separate subscription object. diff --git a/docs/how-to/contacts.rst b/docs/how-to/contacts.rst new file mode 100644 index 0000000..b279c99 --- /dev/null +++ b/docs/how-to/contacts.rst @@ -0,0 +1,32 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-jmap contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +================== +Work with contacts +================== + +List address books +================== + +.. code-block:: python + + address_books = client.get_address_books() + for book in address_books: + print(book.id, book.name) + +If the account doesn't advertise Contacts support, this returns ``[]`` instead of raising. Pass ``account_id`` to browse an address book another user has shared with you. + +Search contacts +=============== + +.. code-block:: python + + results = client.search_contacts(text="alice") + results = client.search_contacts(email="alice@example.com") + + for contact in results: + print(contact.display_name(), contact.primary_email()) + +Omitting both ``text`` and ``email`` returns every contact in the account. Unlike :meth:`~calendaring_jmap.client.JMAPClient.get_address_books`, an account that doesn't support Contacts raises here rather than returning an empty list, since a caller searching for a contact is usually about to act on the result. + +``email`` matches as a substring against any address on the card, on both Cyrus and Stalwart. ``text`` matches as a substring against the whole card on Cyrus, including the name, but on Stalwart it only matches the email address, not the name: a name-only search that finds a contact on Cyrus can silently return nothing on Stalwart. Use ``email`` whenever the value you're searching for might be an email address. diff --git a/docs/how-to/convert.rst b/docs/how-to/convert.rst new file mode 100644 index 0000000..560b919 --- /dev/null +++ b/docs/how-to/convert.rst @@ -0,0 +1,23 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-jmap contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +================================ +Convert iCalendar and JSCalendar +================================ + +``ical_to_jscal`` and ``jscal_to_ical`` are the same conversion functions the client uses internally, available directly if you want to convert between formats without making any JMAP request. + +.. code-block:: python + + from calendaring_jmap.convert import ical_to_jscal, jscal_to_ical + + jscal = ical_to_jscal(ical_str) + ical_str_again = jscal_to_ical(jscal) + +``ical_to_jscal`` processes the first VEVENT in the string; any sibling VEVENTs sharing the same UID with a RECURRENCE-ID are folded into the result's ``recurrenceOverrides`` map, and EXDATE lines become excluded override entries. Pass ``calendar_id`` if the result is going to a real ``CalendarEvent/set`` call, since the server needs ``calendarIds`` set on it: + +.. code-block:: python + + jscal = ical_to_jscal(ical_str, calendar_id=calendar_id) + +Both functions raise ``ValueError`` on malformed input, the same errors :doc:`errors` describes for ``create_event`` and ``update_event``, since those methods call ``ical_to_jscal`` internally. See :doc:`../explanation/design` for the conversion layer's own design decisions. diff --git a/docs/how-to/errors.rst b/docs/how-to/errors.rst index 062653f..f5cc6f5 100644 --- a/docs/how-to/errors.rst +++ b/docs/how-to/errors.rst @@ -33,9 +33,9 @@ Catch specific error types See :doc:`../reference/errors` for what each error class means and the full list of JMAP method error types. Malformed iCalendar input -========================== +========================= -``create_event``, ``send_invite``, ``update_event``, and the calendar/object convenience methods that call them (``JMAPCalendar.add_event``, ``JMAPCalendarObject.save``) convert your iCalendar string to JSCalendar before sending it, and raise a plain ``ValueError``, not a ``JMAPError`` subclass, if it's missing a mandatory property (``UID``, ``DTSTART``) or has a negative duration. This happens client-side, before any request reaches the server, so catch it separately if you're constructing iCalendar strings by hand rather than passing through data a server already returned to you: +``create_event``, ``send_invite``, ``update_event``, and the calendar/object convenience methods that call them (``JMAPCalendar.add_event``, ``JMAPCalendarObject.save``) convert your iCalendar string to JSCalendar before sending it. If it's missing a mandatory property (``UID``, ``DTSTART``) or has a negative duration, they raise a plain ``ValueError``, not a ``JMAPError`` subclass. This happens client-side, before any request reaches the server. Catch it if you're constructing iCalendar strings by hand rather than passing through data a server already returned to you: .. code-block:: python diff --git a/docs/how-to/events.rst b/docs/how-to/events.rst index 12e21bf..7254de8 100644 --- a/docs/how-to/events.rst +++ b/docs/how-to/events.rst @@ -45,7 +45,7 @@ Or by its JMAP event ID, directly on the client, for example if you already have obj = client.get_event(event_id) -Both return a :class:`~calendaring_jmap.objects.calendar_object.JMAPCalendarObject`. Call :meth:`~calendaring_jmap.objects.calendar_object.JMAPCalendarObject.get_icalendar_instance` on it for an :class:`icalendar.Calendar`, or :meth:`~calendaring_jmap.objects.calendar_object.JMAPCalendarObject.get_data` for the raw JSCalendar dict. +Both return a :class:`~calendaring_jmap.objects.calendar_object.JMAPCalendarObject`. Call :meth:`~calendaring_jmap.objects.calendar_object.JMAPCalendarObject.get_icalendar_instance` on it for an :class:`icalendar.Calendar `, or :meth:`~calendaring_jmap.objects.calendar_object.JMAPCalendarObject.get_data` for the raw JSCalendar dict. Update an event =============== @@ -102,10 +102,30 @@ Use :meth:`~calendaring_jmap.objects.calendar.JMAPCalendar.search` on a calendar All parameters are optional. Omitting all of them returns every event in the calendar. Results are a list of :class:`~calendaring_jmap.objects.calendar_object.JMAPCalendarObject`. The search uses a single batched JMAP request (``CalendarEvent/query`` plus a result reference into ``CalendarEvent/get``), so only one HTTP round trip is made regardless of how many events match. +Search across every calendar +============================ + +:meth:`~calendaring_jmap.client.JMAPClient.search_events` searches the whole account rather than one calendar, and adds four filters ``cal.search()`` doesn't have: + +.. code-block:: python + + results = client.search_events( + has_attachment=True, + participant_email="alice@example.com", + participation_status="accepted", + participant_role="chair", + ) + +These four filter client-side, in Python, against whatever the server-side filters already matched, since none of them have a corresponding JMAP ``FilterCondition`` property. All filters combine as AND, but the three participant-based ones are each satisfied independently across an event's participants: an event where one participant matches ``participant_email`` and a different participant matches ``participant_role`` still matches both. + +Cyrus drops an attachment Link's ``rel: "enclosure"`` on read-back (seen against a running instance), so ``has_attachment=True`` misses real attachments there. Both Cyrus and Stalwart drop the ``attendee`` role from a participant's ``roles`` map, so ``participant_role="attendee"`` misses matches on both; a participant who is also ``chair`` keeps that role and still matches on it. + +Results from ``search_events`` have no ``parent`` calendar set, unlike results from ``cal.search()``, so :meth:`~calendaring_jmap.objects.calendar_object.JMAPCalendarObject.save` raises :class:`~calendaring_jmap.error.JMAPMethodError` on them; use ``cal.search()`` instead when you need to edit results in place with ``save()``. + Use the async client ==================== -:class:`~calendaring_jmap.async_client.AsyncJMAPClient` mirrors every method of :class:`~calendaring_jmap.client.JMAPClient` as a coroutine. Use it as an ``async with`` context manager (sync ``with`` is not supported): +:class:`~calendaring_jmap.async_client.AsyncJMAPClient` mirrors every method of :class:`~calendaring_jmap.client.JMAPClient` as a coroutine. Use it as an ``async with`` context manager (sync ``with`` isn't supported): .. code-block:: python @@ -132,4 +152,4 @@ Use the async client asyncio.run(main()) -Event CRUD, search, sync, and task operations are all available as coroutines with identical signatures. The async client uses ``niquests.AsyncSession`` internally, so niquests is a required dependency for async use. +Event CRUD, search, sync, and task operations are all available as coroutines with identical signatures. The async client uses ``niquests.AsyncSession`` internally; niquests is already a required dependency, so no extra install step is needed for async use. diff --git a/docs/how-to/example-sync-cache.rst b/docs/how-to/example-sync-cache.rst new file mode 100644 index 0000000..8b811f1 --- /dev/null +++ b/docs/how-to/example-sync-cache.rst @@ -0,0 +1,96 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-jmap contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +================================ +Sync a calendar to a local cache +================================ + +A complete script that keeps a SQLite cache of one calendar's events in sync, using :doc:`authentication `, :doc:`incremental sync `, and :doc:`error handling ` together. + +.. code-block:: python + + import sqlite3 + + from calendaring_jmap import get_jmap_client + from calendaring_jmap.error import JMAPMethodError + + DB_PATH = "events.db" + + + def init_db(conn): + conn.execute( + "CREATE TABLE IF NOT EXISTS events (" + " id TEXT PRIMARY KEY," + " calendar_id TEXT NOT NULL," + " ical TEXT NOT NULL" + ")" + ) + conn.execute( + "CREATE TABLE IF NOT EXISTS sync_state (" + " calendar_id TEXT PRIMARY KEY," + " token TEXT NOT NULL" + ")" + ) + conn.commit() + + + def load_token(conn, calendar_id): + row = conn.execute( + "SELECT token FROM sync_state WHERE calendar_id = ?", (calendar_id,) + ).fetchone() + return row[0] if row else None + + + def save_token(conn, calendar_id, token): + conn.execute( + "INSERT INTO sync_state (calendar_id, token) VALUES (?, ?) " + "ON CONFLICT(calendar_id) DO UPDATE SET token = excluded.token", + (calendar_id, token), + ) + conn.commit() + + + def sync_calendar(client, conn, calendar_id): + token = load_token(conn, calendar_id) + if token is None: + token = client.get_sync_token() + save_token(conn, calendar_id, token) + return + + try: + added, modified, deleted, token = client.get_objects_by_sync_token(token) + except JMAPMethodError as e: + if e.error_type == "serverPartialFail": + # The server dropped our baseline; start over from a fresh token. + token = client.get_sync_token() + save_token(conn, calendar_id, token) + return + raise + + for obj in list(added) + list(modified): + ical_str = obj.get_icalendar_instance().to_ical().decode() + conn.execute( + "INSERT INTO events (id, calendar_id, ical) VALUES (?, ?, ?) " + "ON CONFLICT(id) DO UPDATE SET ical = excluded.ical", + (obj.id, calendar_id, ical_str), + ) + for event_id in deleted: + conn.execute("DELETE FROM events WHERE id = ?", (event_id,)) + + save_token(conn, calendar_id, token) + conn.commit() + + + if __name__ == "__main__": + conn = sqlite3.connect(DB_PATH) + init_db(conn) + + with get_jmap_client() as client: + for cal in client.get_calendars(): + sync_calendar(client, conn, cal.id) + + conn.close() + +This reads configuration the same way every other example does, through :func:`~calendaring_jmap.get_jmap_client`'s explicit-argument/environment-variable/config-file chain; see :doc:`authenticate` for how to set that up. + +The first run for a calendar has no stored token, so it only records a baseline and caches nothing; the next run starts fetching real deltas. :meth:`~calendaring_jmap.client.JMAPClient.get_objects_by_sync_token`'s :class:`~calendaring_jmap.error.JMAPMethodError` with ``error_type="serverPartialFail"`` means the server truncated the change list; the same :doc:`error-handling pattern ` applies here as anywhere else this error can occur. diff --git a/docs/how-to/free-busy.rst b/docs/how-to/free-busy.rst new file mode 100644 index 0000000..c9f3f7c --- /dev/null +++ b/docs/how-to/free-busy.rst @@ -0,0 +1,36 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-jmap contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +=============== +Check free/busy +=============== + +.. code-block:: python + + availability = client.get_availability( + account_ids=[account_id], + start="2026-01-15T00:00:00", + end="2026-01-16T00:00:00", + ) + for interval in availability[account_id]: + print(interval.start, interval.end, interval.busy_status) + +Each result is a list of :class:`~calendaring_jmap.objects.busy_interval.BusyInterval` objects. ``start`` and ``end`` are treated as UTC. + +Only reports your own availability, never another user's: checking someone else's by email would need resolving that email to a Principal ID first, which this client doesn't support yet. Pass your own account's ID as the only entry in ``account_ids``. + +Request event details +===================== + +.. code-block:: python + + availability = client.get_availability( + account_ids=[account_id], + start="2026-01-15T00:00:00", + end="2026-01-16T00:00:00", + show_details=True, + ) + +Populates each interval's ``event`` where the server permits it. Stalwart needs the account to support returning event details at all; without that, ``event`` comes back ``None`` even with ``show_details=True``. Cyrus returns details by default. + +If the server doesn't support the primary method for a given account, :meth:`~calendaring_jmap.client.JMAPClient.get_availability` falls back to scanning that account's own calendar events directly and computing busy intervals from their ``freeBusyStatus``. This happens automatically; no separate call is needed. diff --git a/docs/how-to/index.rst b/docs/how-to/index.rst new file mode 100644 index 0000000..66ccd54 --- /dev/null +++ b/docs/how-to/index.rst @@ -0,0 +1,30 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-jmap contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +============= +How-to guides +============= + +.. meta:: + :description: Task-focused guides for one specific thing at a time, covering calendars, events, scheduling, attachments, sync, and more. + +Task-focused guides for one specific thing at a time: calendars, events, scheduling, attachments, sync, tasks, and more. Each one assumes you already have a working client; see :doc:`../tutorials/quickstart` if you don't yet. + +.. toctree:: + :maxdepth: 1 + + install + authenticate + calendars + events + attachments + scheduling + free-busy + sync + push-notifications + tasks + contacts + convert + logging + errors + example-sync-cache diff --git a/docs/how-to/install.rst b/docs/how-to/install.rst index b12a567..b3b6fe1 100644 --- a/docs/how-to/install.rst +++ b/docs/how-to/install.rst @@ -9,6 +9,16 @@ Install pip install calendaring-jmap +No separate install step is needed for async support: niquests, the HTTP library this package uses, is always installed and provides it directly. + +If niquests can't be installed in your environment, the ``requests`` extra provides a fallback HTTP library: + +.. code-block:: bash + + pip install calendaring-jmap[requests] + +The package ships inline type hints and a ``py.typed`` marker (:pep:`561`), so a type checker reads them directly without a separate stub package. + From source =========== diff --git a/docs/how-to/logging.rst b/docs/how-to/logging.rst new file mode 100644 index 0000000..990b9f2 --- /dev/null +++ b/docs/how-to/logging.rst @@ -0,0 +1,34 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-jmap contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +============== +Enable logging +============== + +calendaring-jmap logs through the standard library under the ``calendaring_jmap`` logger name, both the sync and async client: + +.. code-block:: python + + import logging + + logging.getLogger("calendaring_jmap").setLevel(logging.DEBUG) + logging.basicConfig() + +At ``DEBUG``, every JMAP request logs its destination URL and how many method calls it batched. At ``WARNING`` (the level a caller typically cares about by default), the library logs the cases below, each one a place where it silently chose a fallback instead of raising. + +Conversion warnings +=================== + +``ical_to_jscal`` logs a warning, rather than raising, for input that's technically malformed but recoverable: + +- More than one ``RRULE``/``EXRULE`` line on a VEVENT: only the first is kept. +- More than one ``URL`` line on a VEVENT (RFC 5545 section 3.8.4.6 allows at most one): only the first is kept. +- More than one ``VCONFERENCE`` subcomponent sharing the same URI: only the first is kept. +- A ``VALARM`` with no ``TRIGGER`` property: that one alarm is skipped; the rest of the event still converts. + +``fixup`` (the step that corrects non-compliant iCalendar before conversion; see :doc:`../explanation/design` for why it exists) logs when it actually changes something, at a rate-limited level. Each power-of-two occurrence (the first, second, fourth, eighth, and further doublings) logs at ``WARNING``; every other occurrence logs at ``DEBUG``. This keeps a feed with many non-compliant events from flooding your logs with the same warning, while still surfacing it periodically instead of going silent after the first one. + +Account-level fallbacks +======================= + +``get_address_books`` logs a warning and returns an empty list, rather than raising, when the session's own account doesn't advertise Contacts support; see :doc:`contacts` for when this applies versus when it raises instead. diff --git a/docs/how-to/push-notifications.rst b/docs/how-to/push-notifications.rst new file mode 100644 index 0000000..ce49c2e --- /dev/null +++ b/docs/how-to/push-notifications.rst @@ -0,0 +1,51 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-jmap contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +================== +Push notifications +================== + +Subscribing requires a publicly reachable HTTPS endpoint the server can POST to; it won't work behind localhost without tunneling. + +Subscribe and verify +==================== + +.. code-block:: python + + subscription_id = client.subscribe_push( + callback_url="https://example.com/jmap-push", + device_client_id="my-app-device-12345", + ) + +``device_client_id`` is a value you choose, not one the client generates for you: the spec requires it to differ per device and per vendor, and to not contain an unobfuscated device ID, so this client can't generate a safe one on your behalf. Neither test server substitutes or validates it, so a fixed value reused across installs will collide across devices. + +Creating a subscription doesn't start push notifications immediately. The server POSTs a ``PushVerification`` object, containing a ``verificationCode``, to ``callback_url`` right after this call. Your own ``callback_url`` endpoint has to receive that POST and extract the code: this client is an API client, not an HTTP server, so it can't receive the POST itself. + +.. code-block:: python + + # In your callback_url endpoint's handler, after extracting + # verification_code from the PushVerification payload it received: + client.confirm_push_verification(subscription_id, verification_code) + +The server won't push any further notifications until this call succeeds. Stalwart rejects a wrong code with ``invalidProperties`` rather than silently ignoring it; Cyrus doesn't implement ``PushSubscription`` at all, so this doesn't arise there. + +Renew a subscription +==================== + +.. code-block:: python + + client.renew_push(subscription_id, expires="2026-03-01T00:00:00Z") + +Pass ``expires=None`` to request no expiration at all. The server may cap or otherwise modify what you ask for. + +Unsubscribe +=========== + +.. code-block:: python + + client.unsubscribe_push(subscription_id) + +Server support +============== + +Cyrus doesn't implement ``PushSubscription/set`` at all (``unknownMethod`` on every call in this section, tested directly). Stalwart implements the full lifecycle correctly, but echoes an ``accountId`` in every response even though PushSubscription isn't account-scoped and the spec says it shouldn't carry one; this client ignores it. diff --git a/docs/how-to/scheduling.rst b/docs/how-to/scheduling.rst new file mode 100644 index 0000000..0ebca93 --- /dev/null +++ b/docs/how-to/scheduling.rst @@ -0,0 +1,42 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-jmap contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +========================== +Scheduling and invitations +========================== + +Send an invitation +================== + +.. code-block:: python + + event_id = client.send_invite(calendar_id, ical_str) + +Identical to :meth:`~calendaring_jmap.client.JMAPClient.create_event`, except the server dispatches iTIP invitations to the event's participants (ATTENDEE/ORGANIZER properties in ``ical_str``). Use :meth:`~calendaring_jmap.client.JMAPClient.create_event` itself for an event with no participants to notify. + +Respond to an invitation +======================== + +.. code-block:: python + + client.accept_invitation(event_id, own_email="alice@example.com") + client.decline_invitation(event_id, own_email="alice@example.com") + client.tentatively_accept(event_id, own_email="alice@example.com") + +Each finds the participant on the event matching ``own_email`` and sets only that participant's own ``participationStatus``, notifying the organizer. A non-origin account can never modify any other participant property. + +There's no counter-proposal method: neither RFC 8984 nor the JMAP Calendars draft define an iTIP COUNTER equivalent. Writing ``start``/``duration`` directly as a non-origin account works through the server's own rights model on both Cyrus and Stalwart, but it's a plain update outside the scheduling methods above, not a supported scheduling primitive, so it isn't exposed as a method here. + +Read participant status +======================= + +.. code-block:: python + + from calendaring_jmap.constants import PARTICIPATION_STATUS_ACCEPTED + + obj = client.get_event(event_id) + for participant in obj.get_data().get("participants", {}).values(): + if participant.get("participationStatus") == PARTICIPATION_STATUS_ACCEPTED: + print(participant.get("email"), "has accepted") + +``participants`` is a map keyed by server-assigned participant ID. An absent ``participationStatus`` means ``"needs-action"`` (:rfc:`8984#section-4.4.6`), the spec's own default for a participant who hasn't responded yet. diff --git a/docs/how-to/sync.rst b/docs/how-to/sync.rst index 4b4d5c8..51af70f 100644 --- a/docs/how-to/sync.rst +++ b/docs/how-to/sync.rst @@ -5,7 +5,7 @@ Sync incrementally ================== -JMAP's state-based sync lets you fetch only what changed since the last call, without scanning the full calendar. See :doc:`../explanation/design` for why this differs from CalDAV-style polling. +JMAP's state-based sync lets you fetch only what changed since the last call, without scanning the full calendar. Fetch a token, then the delta ============================= @@ -27,7 +27,7 @@ Fetch a token, then the delta for event_id in deleted: print("Deleted ID:", event_id) -``added`` and ``modified`` are lists of :class:`~calendaring_jmap.objects.calendar_object.JMAPCalendarObject`. ``deleted`` is a list of event IDs: those objects no longer exist on the server, so their data cannot be fetched. The fourth element is the server's new sync token. Chaining straight from it avoids the race window a separate :meth:`~calendaring_jmap.client.JMAPClient.get_sync_token` round trip would open. +``added`` and ``modified`` are lists of :class:`~calendaring_jmap.objects.calendar_object.JMAPCalendarObject`. ``deleted`` is a list of event IDs: those objects no longer exist on the server, so their data can't be fetched. The fourth element is the server's new sync token. Chaining straight from it avoids the race window a separate :meth:`~calendaring_jmap.client.JMAPClient.get_sync_token` round trip would open. Handle a truncated change list ============================== diff --git a/docs/how-to/tasks.rst b/docs/how-to/tasks.rst index 93d592b..c6c0ba5 100644 --- a/docs/how-to/tasks.rst +++ b/docs/how-to/tasks.rst @@ -5,7 +5,7 @@ Use tasks ========= -Task support requires a server implementing ``urn:ietf:params:jmap:tasks`` (the JMAP Tasks specification). If the server does not support this capability, :meth:`~calendaring_jmap.client.JMAPClient.get_task_lists` raises :class:`~calendaring_jmap.error.JMAPMethodError`. +Task support requires a server implementing ``urn:ietf:params:jmap:tasks`` (the JMAP Tasks specification). If the server doesn't support this capability, :meth:`~calendaring_jmap.client.JMAPClient.get_task_lists` raises :class:`~calendaring_jmap.error.JMAPMethodError`. .. note:: @@ -65,3 +65,31 @@ Delete a task .. code-block:: python client.delete_task(task_id) + +Sync incrementally +================== + +.. code-block:: python + + token = client.get_task_sync_token() + + # ... time passes, tasks are created, modified, or deleted ... + + added, modified, deleted, token = client.get_tasks_by_sync_token(token) + +``added`` and ``modified`` are raw JMAP Task dicts. ``deleted`` is a list of task IDs. Chaining the sync token straight from the previous call's return value, rather than fetching a fresh one with :meth:`~calendaring_jmap.client.JMAPClient.get_task_sync_token`, avoids the race window a separate round trip would open. See :doc:`sync` for the same pattern applied to events, including how to persist a token between runs. + +:meth:`~calendaring_jmap.client.JMAPClient.get_tasks_by_sync_token` raises :class:`~calendaring_jmap.error.JMAPMethodError` if the server reports ``hasMoreChanges: true``; call :meth:`~calendaring_jmap.client.JMAPClient.get_task_sync_token` for a fresh baseline and re-sync from scratch. + +Search tasks +============ + +.. code-block:: python + + results = client.search_tasks(text="pull request") + results = client.search_tasks(due_before="2026-03-01T00:00:00") + results = client.search_tasks(progress="needs-action") + +Omitting every argument returns every task in the account. All filters combine as AND. + +``due_before`` and ``due_after`` compare against each task's ``due`` as a plain string; a task with no ``due`` set never matches either filter. ``progress`` treats an absent value as ``"needs-action"``, the spec's own default for a task with no participants, which covers every task this client can create. diff --git a/docs/index.rst b/docs/index.rst index 884c5e7..63e74e3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -5,7 +5,10 @@ calendaring-jmap ================ -Calendar operations over JMAP (:rfc:`8620` + draft-ietf-jmap-calendars). +.. meta:: + :description: A Python client library for calendar operations over JMAP (RFC 8620, JMAP core) and the JMAP Calendars draft, with synchronous and asynchronous clients. + +A Python client library for calendar operations over JMAP (:rfc:`8620`, JMAP core) and the JMAP Calendars draft, with synchronous and asynchronous clients. .. grid:: 2 :gutter: 2 @@ -20,7 +23,7 @@ Calendar operations over JMAP (:rfc:`8620` + draft-ietf-jmap-calendars). :link: how-to/install :link-type: doc - Install, authenticate, and work with events, sync, and tasks. + Install, authenticate, and work with calendars, events, and more. .. grid-item-card:: 📖 Reference :link: reference/index @@ -48,42 +51,11 @@ Calendar operations over JMAP (:rfc:`8620` + draft-ietf-jmap-calendars). .. toctree:: :hidden: - :caption: Tutorials tutorials/quickstart - -.. toctree:: - :hidden: - :caption: How-to guides - - how-to/install - how-to/authenticate - how-to/calendars - how-to/events - how-to/sync - how-to/tasks - how-to/errors - -.. toctree:: - :hidden: - :caption: Reference - - reference/index - reference/client - reference/async_client - reference/objects - reference/errors - reference/changelog - -.. toctree:: - :hidden: - :caption: Explanation - - explanation/design - -.. toctree:: - :hidden: - :caption: Project - + how-to/index + Reference + explanation/index contribute security + release diff --git a/docs/reference/async_client.rst b/docs/reference/async_client.rst index 389eb18..19e1b96 100644 --- a/docs/reference/async_client.rst +++ b/docs/reference/async_client.rst @@ -4,5 +4,8 @@ :mod:`async_client`: Async JMAP calendar client =============================================== +.. meta:: + :description: AsyncJMAPClient, the coroutine-based counterpart to JMAPClient. + .. automodule:: calendaring_jmap.async_client :members: diff --git a/docs/reference/changelog.rst b/docs/reference/changelog.rst deleted file mode 100644 index 826643f..0000000 --- a/docs/reference/changelog.rst +++ /dev/null @@ -1,4 +0,0 @@ -.. SPDX-FileCopyrightText: 2026 calendaring-jmap contributors -.. SPDX-License-Identifier: AGPL-3.0-or-later - -.. include:: ../../CHANGES.rst diff --git a/docs/reference/client.rst b/docs/reference/client.rst index f4ca52b..18be53a 100644 --- a/docs/reference/client.rst +++ b/docs/reference/client.rst @@ -4,5 +4,8 @@ :mod:`client`: JMAP calendar client =================================== +.. meta:: + :description: JMAPClient, the synchronous client calendaring-jmap's calendar, event, scheduling, and attachment methods live on. + .. automodule:: calendaring_jmap.client :members: diff --git a/docs/reference/constants.rst b/docs/reference/constants.rst new file mode 100644 index 0000000..655d7f8 --- /dev/null +++ b/docs/reference/constants.rst @@ -0,0 +1,11 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-jmap contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +:mod:`constants`: JMAP capability URNs and wire values +====================================================== + +.. meta:: + :description: Capability URNs, participation status values, and other JMAP wire constants calendaring-jmap exports for reuse. + +.. automodule:: calendaring_jmap.constants + :members: diff --git a/docs/reference/convert.rst b/docs/reference/convert.rst new file mode 100644 index 0000000..6abb186 --- /dev/null +++ b/docs/reference/convert.rst @@ -0,0 +1,20 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-jmap contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +:mod:`convert`: JSCalendar and iCalendar conversion +=================================================== + +.. meta:: + :description: ical_to_jscal and jscal_to_ical, the standalone iCalendar/JSCalendar conversion functions, usable without a JMAP connection. + +``ical_to_jscal`` and ``jscal_to_ical`` convert between iCalendar text and JSCalendar dicts directly, with no JMAP client or network connection involved. + +.. automodule:: calendaring_jmap.convert + :members: + :exclude-members: ical_to_jscal, jscal_to_ical + +.. automodule:: calendaring_jmap.convert.ical_to_jscal + :members: + +.. automodule:: calendaring_jmap.convert.jscal_to_ical + :members: diff --git a/docs/reference/index.rst b/docs/reference/index.rst index 5c822c9..d4d7ca8 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -4,8 +4,28 @@ :mod:`calendaring_jmap`: Package entry points ============================================= +.. meta:: + :description: get_jmap_client, get_async_jmap_client, and get_connection_params, the top-level functions that read configuration and return a connected client. + +``get_jmap_client``, ``get_async_jmap_client``, and ``get_connection_params`` are the functions most code imports directly from ``calendaring_jmap``. + .. autofunction:: calendaring_jmap.get_jmap_client .. autofunction:: calendaring_jmap.get_async_jmap_client -Everything else exported from ``calendaring_jmap`` (:class:`~calendaring_jmap.client.JMAPClient`, :class:`~calendaring_jmap.async_client.AsyncJMAPClient`, the calendar objects, and the error hierarchy) is documented on its own page; see the sidebar. +.. autofunction:: calendaring_jmap.get_connection_params + +Everything else exported from ``calendaring_jmap`` (:class:`~calendaring_jmap.client.JMAPClient`, :class:`~calendaring_jmap.async_client.AsyncJMAPClient`, the calendar objects, and the error hierarchy) has its own page, listed below. + +.. toctree:: + :maxdepth: 1 + + client + async_client + objects + convert + session + constants + errors + server-compatibility + ../changelog diff --git a/docs/reference/objects.rst b/docs/reference/objects.rst index d22a2ec..a297a4d 100644 --- a/docs/reference/objects.rst +++ b/docs/reference/objects.rst @@ -4,6 +4,11 @@ :mod:`objects`: JMAP data objects ================================= +.. meta:: + :description: The calendar, event, busy interval, attachment, and contact objects calendaring-jmap's methods return. + +The data objects calendaring-jmap's methods return: calendars, calendar events, busy intervals, attachments, and contacts. + .. automodule:: calendaring_jmap.objects.calendar :members: diff --git a/docs/reference/server-compatibility.rst b/docs/reference/server-compatibility.rst new file mode 100644 index 0000000..08b59bc --- /dev/null +++ b/docs/reference/server-compatibility.rst @@ -0,0 +1,47 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-jmap contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +==================== +Server compatibility +==================== + +Cyrus and Stalwart are the two servers calendaring-jmap's integration tests run against. This page collects every confirmed difference between them in one place; each row links to the how-to page that covers it in context. + +.. list-table:: + :header-rows: 1 + :widths: 30 25 25 20 + + * - Behavior + - Cyrus + - Stalwart + - See + * - ``PushSubscription/get``/``/set`` + - Not implemented at all (``unknownMethod``) + - Fully implemented + - :doc:`../how-to/push-notifications` + * - Attachment ``rel: "enclosure"`` persistence + - Accepts the write, doesn't reliably persist it on read-back + - Persists correctly + - :doc:`../how-to/attachments`, :doc:`../how-to/events` + * - ``search_events``'s ``participant_role="attendee"`` filter + - Server drops the ``attendee`` role from ``roles``, so this misses matches + - Same gap + - :doc:`../how-to/events` + * - Contact search by ``text`` against the card's name + - Matches as a substring against the name + - Matches only the email address, never the name + - :doc:`../how-to/contacts` + * - ``get_availability`` with ``show_details=True`` + - Returns event details by default + - Needs the account to support returning details at all; ``event`` comes back ``None`` otherwise + - :doc:`../how-to/free-busy` + * - Participant ``sendTo`` on ``CalendarEvent/set`` + - Rejected outright with ``invalidProperties``; ``calendarAddress`` required instead + - Accepts the ``calendarAddress``-only form too + - :doc:`../explanation/design` + * - JMAP Tasks (``urn:ietf:params:jmap:tasks``) + - Not implemented + - Not implemented + - :doc:`../how-to/tasks` + +Counter-proposals (iTIP COUNTER) have no distinct method on either server: a non-origin participant can write ``start``/``duration`` directly as a plain update, through the server's own rights model, on both Cyrus and Stalwart. See :doc:`../how-to/scheduling`. diff --git a/docs/reference/session.rst b/docs/reference/session.rst new file mode 100644 index 0000000..ac2129c --- /dev/null +++ b/docs/reference/session.rst @@ -0,0 +1,11 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-jmap contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +:mod:`session`: JMAP session discovery +====================================== + +.. meta:: + :description: The JMAP session object a server returns at bootstrap, and how calendaring-jmap resolves it into usable API URLs. + +.. automodule:: calendaring_jmap.session + :members: diff --git a/docs/release.rst b/docs/release.rst new file mode 100644 index 0000000..0504bce --- /dev/null +++ b/docs/release.rst @@ -0,0 +1,62 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-jmap contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +======= +Release +======= + +This page is for maintainers cutting a new release. It isn't something a contributor opening a PR needs to read. + +Versioning +========== + +calendaring-jmap has no release branches: every release ships from ``main``. The version number comes entirely from the git tag, read at build time by ``hatch-vcs``; nothing in the source tree stores it. ``src/calendaring_jmap/_version.py`` is generated during the build and isn't tracked in git. + +Cut a release +============= + +#. Confirm ``main`` is up to date and its CI is green. + + .. code-block:: bash + + git checkout main + git pull + +#. Build the :ref:`change log ` from the pending fragments in ``news/``, replacing ``1.2.0`` with the real version: + + .. code-block:: bash + + towncrier build --yes --version 1.2.0 + + This writes the new section into ``docs/changelog.rst`` and deletes the fragments it consumed. Review the diff; towncrier sorts entries alphabetically within each category, not in the order the fragments were added. + +#. Commit directly to ``main``. This one commit is the exception to :ref:`commits-and-prs`'s one-PR-per-change rule: it's a mechanical, maintainer-only step with nothing for a reviewer to meaningfully review. + + .. code-block:: bash + + git add docs/changelog.rst news/ + git commit -m "chore: build 1.2.0 changelog" + git push + +#. Tag the commit you just pushed, and push the tag: + + .. code-block:: bash + + git tag v1.2.0 + git push origin v1.2.0 + +Pushing the tag triggers the ``release`` GitHub Actions workflow: it runs the full test suite, builds the package, publishes to PyPI via trusted publishing (no stored token), and creates a GitHub release with autogenerated notes. Nothing past the tag push is manual. + +Read the Docs builds the new version automatically from the same tag push; no separate step is needed there. + +Maintainer access +================= + +Releasing calendaring-jmap requires: + +``Admin`` access to the `GitHub repository `_ + Needed to push directly to ``main`` and push tags. +``Owner`` or ``Maintainer`` access to the `PyPI project `_ + PyPI publishing uses `trusted publishing `_ (OIDC), configured against this repository's ``release`` workflow and its ``pypi`` environment; no PyPI API token is stored anywhere. A PyPI account with access can revoke or reconfigure this. +``Maintainer`` access to the `Read the Docs project `_ + Needed to manage the Read the Docs build configuration itself, though a normal release build needs no manual action there. diff --git a/docs/security.rst b/docs/security.rst index 3d38155..fe7eb30 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -20,7 +20,7 @@ Server-controlled API URL :rfc:`8620#section-2` has the client discover its API endpoint at runtime: a GET to ``/.well-known/jmap`` returns a Session object whose ``apiUrl`` field is where every subsequent request goes. This is how JMAP works, not something calendaring-jmap adds on top, and it means the session response controls where credentials get sent next. -calendaring-jmap only corrects ``apiUrl`` when it names the same host as the session endpoint but a different port or scheme (some servers do this). A session response naming a genuinely different host is followed as-is. Combined with TLS, this is safe against a passive network observer; it does not protect against a session endpoint that is itself malicious or already compromised, since that is the entity the protocol asks you to trust. Only point the client at a session URL you control or trust, and use HTTPS for it. +calendaring-jmap only corrects ``apiUrl`` when it names the same host as the session endpoint but a different port or scheme (some servers do this). A session response naming a genuinely different host is followed as-is. Combined with TLS, this is safe against a passive network observer. It doesn't protect against a session endpoint that's itself malicious or already compromised, since that's the entity the protocol asks you to trust. Only point the client at a session URL you control or trust, and use HTTPS for it. TLS and credentials ------------------- @@ -29,6 +29,11 @@ calendaring-jmap does no custom TLS handling: it relies entirely on its HTTP lib Credentials are read from explicit arguments, ``JMAP_URL``/``JMAP_USERNAME``/``JMAP_PASSWORD`` environment variables, or a YAML config file (default ``~/.config/calendaring-jmap/calendar.yaml``). The config file is stored and read as plaintext; set restrictive file permissions on it (for example, ``chmod 600``) if you use it. +Push subscription callback URL +------------------------------ + +:meth:`~calendaring_jmap.client.JMAPClient.subscribe_push`'s ``callback_url`` receives an unauthenticated HTTP POST from the server, carrying a verification code. :rfc:`8620#section-7.2.2` requires this code to have enough entropy that it can't be guessed, and requires you to submit it back before the server sends any further notifications there; this exists specifically so the server can confirm you control that URL before trusting it with real data, not so a server operator or a third party watching the endpoint can intercept anything useful on its own. Use an endpoint only you control, over HTTPS, the same guidance as the session URL above. + Untrusted calendar data ----------------------- @@ -43,4 +48,4 @@ Calendar data can contain personal information (names, locations, meeting conten Dependencies ============ -calendaring-jmap depends on `niquests `_ for HTTP, `icalendar `_ for iCalendar parsing, and `PyYAML `_ for config file loading (via ``yaml.safe_load``, which doesn't execute arbitrary code). `requests `_ is an optional fallback (the ``requests`` extra) for environments where niquests isn't available; async support additionally needs niquests's own ``asyncio`` extra. See ``pyproject.toml`` for the current version constraints. +calendaring-jmap depends on `niquests `_ for HTTP, `icalendar `_ for iCalendar parsing, and `PyYAML `_ for config file loading (via ``yaml.safe_load``, which doesn't execute arbitrary code). `requests `_ is an optional fallback (the ``requests`` extra) for environments where niquests isn't available. See ``pyproject.toml`` for the current version constraints. diff --git a/docs/tutorials/quickstart.rst b/docs/tutorials/quickstart.rst index 55f249c..9442254 100644 --- a/docs/tutorials/quickstart.rst +++ b/docs/tutorials/quickstart.rst @@ -43,6 +43,8 @@ Next steps ========== - :doc:`../how-to/authenticate` for other ways to supply credentials. +- :doc:`../how-to/calendars` to create, share, and manage calendars themselves, beyond listing them. - :doc:`../how-to/events` to create, read, update, and delete events. - :doc:`../how-to/errors` to handle what can go wrong. +- The sidebar's **How-to guides** section covers everything else this client can do. - :doc:`../explanation/design` to understand how the client is put together, and why. diff --git a/news/+docs-attachments-how-to.documentation.rst b/news/+docs-attachments-how-to.documentation.rst new file mode 100644 index 0000000..83c7d71 --- /dev/null +++ b/news/+docs-attachments-how-to.documentation.rst @@ -0,0 +1 @@ +Added a how-to page for uploading, attaching, listing, and downloading event attachments. diff --git a/news/+docs-autodoc-coverage.documentation.rst b/news/+docs-autodoc-coverage.documentation.rst new file mode 100644 index 0000000..da2f0e8 --- /dev/null +++ b/news/+docs-autodoc-coverage.documentation.rst @@ -0,0 +1 @@ +Added reference pages for ``constants``, ``session``, and ``convert``, which previously had no documentation of their own despite being part of the public API. diff --git a/news/+docs-broken-cross-references.documentation.rst b/news/+docs-broken-cross-references.documentation.rst new file mode 100644 index 0000000..415e91b --- /dev/null +++ b/news/+docs-broken-cross-references.documentation.rst @@ -0,0 +1 @@ +Fixed several cross-references in the API reference that rendered as plain, unlinked text instead of linking to the method or class they named. diff --git a/news/+docs-calendar-management-how-to.documentation.rst b/news/+docs-calendar-management-how-to.documentation.rst new file mode 100644 index 0000000..6ce1979 --- /dev/null +++ b/news/+docs-calendar-management-how-to.documentation.rst @@ -0,0 +1 @@ +The calendars how-to page covers the full calendar management surface (create, update, delete, share, default alerts, subscriptions), not just listing. diff --git a/news/+docs-calendar-share-with-readback.documentation.rst b/news/+docs-calendar-share-with-readback.documentation.rst new file mode 100644 index 0000000..0f0c138 --- /dev/null +++ b/news/+docs-calendar-share-with-readback.documentation.rst @@ -0,0 +1 @@ +The calendars how-to page now covers reading back a calendar's current sharing configuration from its share_with field, not just writing it with share_calendar. diff --git a/news/+docs-close-method.documentation.rst b/news/+docs-close-method.documentation.rst new file mode 100644 index 0000000..2082030 --- /dev/null +++ b/news/+docs-close-method.documentation.rst @@ -0,0 +1 @@ +Documented ``close()``/``aclose()`` for releasing the HTTP connection without using the client as a context manager. diff --git a/news/+docs-config-precedence-fix.documentation.rst b/news/+docs-config-precedence-fix.documentation.rst new file mode 100644 index 0000000..a25ae86 --- /dev/null +++ b/news/+docs-config-precedence-fix.documentation.rst @@ -0,0 +1 @@ +Corrected the authenticate how-to's description of configuration precedence: an explicit ``url`` argument doesn't merge in username, password, auth type, or timeout from environment variables or a config file. diff --git a/news/+docs-contacts-how-to.documentation.rst b/news/+docs-contacts-how-to.documentation.rst new file mode 100644 index 0000000..0760a0b --- /dev/null +++ b/news/+docs-contacts-how-to.documentation.rst @@ -0,0 +1 @@ +Added a how-to page for listing address books and searching contacts. diff --git a/news/+docs-conversion-layer-explanation.documentation.rst b/news/+docs-conversion-layer-explanation.documentation.rst new file mode 100644 index 0000000..4b26cc8 --- /dev/null +++ b/news/+docs-conversion-layer-explanation.documentation.rst @@ -0,0 +1 @@ +Added explanation-page coverage for the conversion layer's own design: why CONFERENCE and VCONFERENCE correlate by URI, and why recurrence override patches compare map-shaped properties by value instead of by key. diff --git a/news/+docs-convert-how-to.documentation.rst b/news/+docs-convert-how-to.documentation.rst new file mode 100644 index 0000000..543986e --- /dev/null +++ b/news/+docs-convert-how-to.documentation.rst @@ -0,0 +1 @@ +Added a how-to page for using ``ical_to_jscal``/``jscal_to_ical`` directly, without making any JMAP request. diff --git a/news/+docs-design-sections-added.documentation.rst b/news/+docs-design-sections-added.documentation.rst new file mode 100644 index 0000000..74f8e49 --- /dev/null +++ b/news/+docs-design-sections-added.documentation.rst @@ -0,0 +1 @@ +Added explanation-page sections on participant address handling, alert trigger shape, Link/attachment conversion scope, and recurrence rule time zone conversion. diff --git a/news/+docs-error-hierarchy-explanation.documentation.rst b/news/+docs-error-hierarchy-explanation.documentation.rst new file mode 100644 index 0000000..6e51cc1 --- /dev/null +++ b/news/+docs-error-hierarchy-explanation.documentation.rst @@ -0,0 +1 @@ +Added an explanation-page section for why the error hierarchy is split into a generic base and a JMAP-specific layer, fixing a cross-reference in the errors how-to page that previously pointed at nothing. diff --git a/news/+docs-footer-version-removed.documentation.rst b/news/+docs-footer-version-removed.documentation.rst new file mode 100644 index 0000000..f14ad4d --- /dev/null +++ b/news/+docs-footer-version-removed.documentation.rst @@ -0,0 +1 @@ +Removed the theme/Sphinx version numbers from the docs footer; nobody reading the docs needs to know which Sphinx release built the page. diff --git a/news/+docs-free-busy-how-to.documentation.rst b/news/+docs-free-busy-how-to.documentation.rst new file mode 100644 index 0000000..941f8c8 --- /dev/null +++ b/news/+docs-free-busy-how-to.documentation.rst @@ -0,0 +1 @@ +Added a how-to page for checking free/busy availability. diff --git a/news/+docs-get-connection-params.documentation.rst b/news/+docs-get-connection-params.documentation.rst new file mode 100644 index 0000000..408b882 --- /dev/null +++ b/news/+docs-get-connection-params.documentation.rst @@ -0,0 +1 @@ +Documented ``get_connection_params``, a public function that previously had no reference page. diff --git a/news/+docs-install-extras.documentation.rst b/news/+docs-install-extras.documentation.rst new file mode 100644 index 0000000..5b1254e --- /dev/null +++ b/news/+docs-install-extras.documentation.rst @@ -0,0 +1 @@ +The install how-to page now covers the ``requests`` fallback extra and clarifies that async support needs no separate install step. diff --git a/news/+docs-link-contribute-and-release.documentation.rst b/news/+docs-link-contribute-and-release.documentation.rst new file mode 100644 index 0000000..e9c8468 --- /dev/null +++ b/news/+docs-link-contribute-and-release.documentation.rst @@ -0,0 +1 @@ +Cross-linked contribute.rst and release.rst: the change log section had an anchor nothing pointed to, and contribute.rst never mentioned the release page existed. diff --git a/news/+docs-logging-how-to.documentation.rst b/news/+docs-logging-how-to.documentation.rst new file mode 100644 index 0000000..98ccd8f --- /dev/null +++ b/news/+docs-logging-how-to.documentation.rst @@ -0,0 +1 @@ +Added a how-to page documenting the calendaring_jmap logger and every case where the library logs a warning instead of raising. diff --git a/news/+docs-push-notifications-how-to.documentation.rst b/news/+docs-push-notifications-how-to.documentation.rst new file mode 100644 index 0000000..ebbec6a --- /dev/null +++ b/news/+docs-push-notifications-how-to.documentation.rst @@ -0,0 +1 @@ +Added a how-to page for the full push notification lifecycle: subscribe, confirm verification, renew, unsubscribe. diff --git a/news/+docs-push-security-note.documentation.rst b/news/+docs-push-security-note.documentation.rst new file mode 100644 index 0000000..772817a --- /dev/null +++ b/news/+docs-push-security-note.documentation.rst @@ -0,0 +1 @@ +Documented the security purpose of the push subscription verification handshake and the callback URL trust requirement. diff --git a/news/+docs-py-typed-note.documentation.rst b/news/+docs-py-typed-note.documentation.rst new file mode 100644 index 0000000..4f2ccbe --- /dev/null +++ b/news/+docs-py-typed-note.documentation.rst @@ -0,0 +1 @@ +The install how-to page now mentions the package's py.typed marker. diff --git a/news/+docs-pycal-theme-colors.documentation.rst b/news/+docs-pycal-theme-colors.documentation.rst new file mode 100644 index 0000000..850fed5 --- /dev/null +++ b/news/+docs-pycal-theme-colors.documentation.rst @@ -0,0 +1 @@ +The docs site now uses the Python Calendaring Ecosystem's own colors and fonts, in both light and dark mode, instead of the Sphinx theme's defaults. diff --git a/news/+docs-read-participant-status.documentation.rst b/news/+docs-read-participant-status.documentation.rst new file mode 100644 index 0000000..5780f42 --- /dev/null +++ b/news/+docs-read-participant-status.documentation.rst @@ -0,0 +1 @@ +The scheduling how-to page now covers reading a participant's current status, alongside the existing accept/decline/tentatively-accept coverage. diff --git a/news/+docs-release-process.documentation.rst b/news/+docs-release-process.documentation.rst new file mode 100644 index 0000000..5cea87e --- /dev/null +++ b/news/+docs-release-process.documentation.rst @@ -0,0 +1 @@ +Added a maintainer-facing page documenting the release process. diff --git a/news/+docs-scheduling-how-to.documentation.rst b/news/+docs-scheduling-how-to.documentation.rst new file mode 100644 index 0000000..caca221 --- /dev/null +++ b/news/+docs-scheduling-how-to.documentation.rst @@ -0,0 +1 @@ +Added a how-to page for sending invitations and responding to them (accept, decline, tentatively accept). diff --git a/news/+docs-search-events-how-to.documentation.rst b/news/+docs-search-events-how-to.documentation.rst new file mode 100644 index 0000000..8256715 --- /dev/null +++ b/news/+docs-search-events-how-to.documentation.rst @@ -0,0 +1 @@ +The events how-to page now covers ``search_events``, the account-wide search with the attachment, participant email, participation status, and role filters, alongside the existing single-calendar ``cal.search()`` coverage. diff --git a/news/+docs-seo-meta-tags.documentation.rst b/news/+docs-seo-meta-tags.documentation.rst new file mode 100644 index 0000000..b0b10af --- /dev/null +++ b/news/+docs-seo-meta-tags.documentation.rst @@ -0,0 +1 @@ +Added SEO metadata to every docs page: a canonical link, an accurate description, and a social preview image using the Python Calendaring Ecosystem's own branding. diff --git a/news/+docs-server-compatibility-matrix.documentation.rst b/news/+docs-server-compatibility-matrix.documentation.rst new file mode 100644 index 0000000..ea085f4 --- /dev/null +++ b/news/+docs-server-compatibility-matrix.documentation.rst @@ -0,0 +1 @@ +Added a reference page collecting every confirmed Cyrus/Stalwart behavioral difference in one table, instead of leaving each one scattered across its own how-to page. diff --git a/news/+docs-sync-cache-example.documentation.rst b/news/+docs-sync-cache-example.documentation.rst new file mode 100644 index 0000000..1bbbd05 --- /dev/null +++ b/news/+docs-sync-cache-example.documentation.rst @@ -0,0 +1 @@ +Added a complete example script that keeps a SQLite cache of a calendar's events in sync. diff --git a/news/+docs-task-sync-how-to.documentation.rst b/news/+docs-task-sync-how-to.documentation.rst new file mode 100644 index 0000000..333a27d --- /dev/null +++ b/news/+docs-task-sync-how-to.documentation.rst @@ -0,0 +1 @@ +The tasks how-to page now covers incremental sync and search, alongside the existing create/read/update/delete coverage. diff --git a/news/+fix-docker-readme-claims.documentation.rst b/news/+fix-docker-readme-claims.documentation.rst new file mode 100644 index 0000000..b6f4bed --- /dev/null +++ b/news/+fix-docker-readme-claims.documentation.rst @@ -0,0 +1 @@ +Fixed the Stalwart test-server README's claim that it's used for task integration tests; there are none against either server, and Stalwart's actual exclusive coverage is push notifications. diff --git a/news/+fix-docker-readme-data-claim.documentation.rst b/news/+fix-docker-readme-data-claim.documentation.rst new file mode 100644 index 0000000..f8557fb --- /dev/null +++ b/news/+fix-docker-readme-data-claim.documentation.rst @@ -0,0 +1 @@ +Fixed both Docker test-server READMEs' claim that stopping the container wipes persistent data; neither server's container actually has any to wipe. diff --git a/news/+fix-get-jmap-client-auth-kwarg.bugfix.rst b/news/+fix-get-jmap-client-auth-kwarg.bugfix.rst new file mode 100644 index 0000000..5a6d71d --- /dev/null +++ b/news/+fix-get-jmap-client-auth-kwarg.bugfix.rst @@ -0,0 +1 @@ +``get_jmap_client``/``get_async_jmap_client`` now accept a pre-built ``auth`` object. The ``auth`` keyword argument was silently dropped before reaching the client, so a caller following the documented example got ``JMAPAuthError`` instead of using their own auth object. diff --git a/news/+fix-stale-docs.documentation.2.rst b/news/+fix-stale-docs.documentation.2.rst deleted file mode 100644 index 8d979b2..0000000 --- a/news/+fix-stale-docs.documentation.2.rst +++ /dev/null @@ -1 +0,0 @@ -Added code-style and commit/PR conventions to the contributor guide, including guidance on keeping changelog fragments short and one-per-behavior. diff --git a/news/+fix-title-underline-claim.documentation.rst b/news/+fix-title-underline-claim.documentation.rst new file mode 100644 index 0000000..aff6921 --- /dev/null +++ b/news/+fix-title-underline-claim.documentation.rst @@ -0,0 +1 @@ +Corrected the contributor guide's title-underline rule: Sphinx only warns about an overline/underline mismatch on a page's own title, not on an ordinary section heading. diff --git a/news/+readme-accurate-specs-list.documentation.rst b/news/+readme-accurate-specs-list.documentation.rst new file mode 100644 index 0000000..4e20867 --- /dev/null +++ b/news/+readme-accurate-specs-list.documentation.rst @@ -0,0 +1 @@ +Expanded README's Specs section, which only listed 3 of the 7 specs the client actually implements, missing JSCalendar/iCalendar conversion, Contacts, Sharing/Principals, and Tasks entirely. diff --git a/news/+remove-caldav-framing.documentation.rst b/news/+remove-caldav-framing.documentation.rst new file mode 100644 index 0000000..9a9637c --- /dev/null +++ b/news/+remove-caldav-framing.documentation.rst @@ -0,0 +1 @@ +Removed CalDAV comparisons from the docs and docstrings; JMAP's own behavior is now described on its own terms. diff --git a/news/+remove-nonexistent-async-extra.bugfix.rst b/news/+remove-nonexistent-async-extra.bugfix.rst new file mode 100644 index 0000000..5d8d26a --- /dev/null +++ b/news/+remove-nonexistent-async-extra.bugfix.rst @@ -0,0 +1 @@ +Removed the ``async`` extra. It declared ``niquests[asyncio]``, an extra niquests itself doesn't define; ``pip install calendaring-jmap[async]`` silently installed plain niquests with a pip warning, no different from the base install. Async support has never needed a separate install step: niquests is already a required dependency and ships ``AsyncSession`` unconditionally. diff --git a/pyproject.toml b/pyproject.toml index e206a9a..3bd21a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,6 @@ dependencies = [ ] [project.optional-dependencies] -async = ["niquests[asyncio]"] requests = ["requests>=2.34.2"] dev = [ "pytest==9.1.1", @@ -53,18 +52,21 @@ dev = [ docs = [ "sphinx==9.1.0", "pydata-sphinx-theme==0.21.0", + "matplotlib==3.11.2", "sphinx-autobuild==2025.8.25", "sphinx-copybutton==0.5.2", "sphinx-design==0.7.0", "sphinx-issues==6.0.0", + "sphinx-last-updated-by-git==0.3.8", "sphinx-notfound-page==1.1.0", + "sphinxext-opengraph==0.13.0", ] [project.urls] Repository = "https://github.com/pycalendar/calendaring-jmap" Issues = "https://github.com/pycalendar/calendaring-jmap/issues" Documentation = "https://calendaring-jmap.readthedocs.io/en/stable/" -Changelog = "https://calendaring-jmap.readthedocs.io/en/stable/reference/changelog.html" +Changelog = "https://calendaring-jmap.readthedocs.io/en/stable/changelog.html" [tool.hatch.version] source = "vcs" @@ -112,7 +114,7 @@ testpaths = ["src/calendaring_jmap/tests"] [tool.towncrier] directory = "news/" -filename = "CHANGES.rst" +filename = "docs/changelog.rst" issue_format = "`#{issue} `_" start_string = ".. towncrier release notes start\n" title_format = "{version} ({project_date})" diff --git a/src/calendaring_jmap/__init__.py b/src/calendaring_jmap/__init__.py index e0ef82b..a02add6 100644 --- a/src/calendaring_jmap/__init__.py +++ b/src/calendaring_jmap/__init__.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later """ -Calendar operations over JMAP (:rfc:`8620` + JMAP Calendars). +Calendar operations over JMAP (RFC 8620 + JMAP Calendars). Provides synchronous and asynchronous JMAP clients for calendar listing, event CRUD, incremental sync, and task CRUD. @@ -48,7 +48,7 @@ def get_jmap_client(**kwargs) -> JMAPClient | None: - """Create a :class:`JMAPClient` from configuration. + """Create a :class:`~calendaring_jmap.client.JMAPClient` from configuration. Configuration is read in priority order: @@ -70,7 +70,7 @@ def get_jmap_client(**kwargs) -> JMAPClient | None: def get_async_jmap_client(**kwargs) -> AsyncJMAPClient | None: - """Create an :class:`AsyncJMAPClient` from configuration. + """Create an :class:`~calendaring_jmap.async_client.AsyncJMAPClient` from configuration. Accepts the same arguments and reads configuration from the same sources as :func:`get_jmap_client`. Returns ``None`` if no configuration is found. diff --git a/src/calendaring_jmap/_config.py b/src/calendaring_jmap/_config.py index 7c7e7f5..b9d5010 100644 --- a/src/calendaring_jmap/_config.py +++ b/src/calendaring_jmap/_config.py @@ -21,7 +21,7 @@ from pathlib import Path from typing import Any -_CONN_KEYS = {"url", "username", "password", "auth_type", "timeout"} +_CONN_KEYS = {"url", "username", "password", "auth", "auth_type", "timeout"} _DEFAULT_CONFIG_PATH = Path.home() / ".config" / "calendaring-jmap" / "calendar.yaml" @@ -67,8 +67,8 @@ def get_connection_params( config_file: Explicit path to a YAML config file. Defaults to the ``JMAP_CONFIG_FILE`` environment variable, then ``~/.config/calendaring-jmap/calendar.yaml``. - **explicit_params: ``url``, ``username``, ``password``, ``auth_type``, - ``timeout``. A value of ``None`` is treated as "not supplied", + **explicit_params: ``url``, ``username``, ``password``, ``auth``, + ``auth_type``, ``timeout``. A value of ``None`` is treated as "not supplied", not "unset": this lets a thin CLI wrapper pass every option through unconditionally without wiping out env vars for the ones the user left off. diff --git a/src/calendaring_jmap/_methods/__init__.py b/src/calendaring_jmap/_methods/__init__.py index 66828d9..a7258a4 100644 --- a/src/calendaring_jmap/_methods/__init__.py +++ b/src/calendaring_jmap/_methods/__init__.py @@ -45,7 +45,7 @@ def build_query( """Build a ``/query`` method call tuple. Shared by every ``build_*_query`` function in this package: the shape - of the standard :rfc:`8620#section-5.5` arguments is identical for all + of the standard RFC 8620 section 5.5 arguments is identical for all of them. A function with extra object-specific arguments beyond these (``build_event_query``'s own ``expandRecurrences``/``timeZone``) calls this for the shared part, then adds its own keys to the returned @@ -87,7 +87,7 @@ def build_get_by_query_result( ``/query`` call built with a matching ``query_call_id``. Shared by every ``build_*_get_by_query_result`` function in this - package. Uses a JMAP result reference (:rfc:`8620#section-3.7`) instead + package. Uses a JMAP result reference (RFC 8620 section 3.7) instead of a literal ``ids`` list, so the two calls can be batched into one HTTP request without a round trip between them. diff --git a/src/calendaring_jmap/_methods/calendar.py b/src/calendaring_jmap/_methods/calendar.py index c328401..dd18c09 100644 --- a/src/calendaring_jmap/_methods/calendar.py +++ b/src/calendaring_jmap/_methods/calendar.py @@ -8,8 +8,8 @@ tuples that go into a ``methodCalls`` list, and parse the corresponding ``methodResponses`` entries. -Method shapes follow :rfc:`8620#section-3.3` (get), :rfc:`8620#section-3.4` -(changes), :rfc:`8620#section-3.5` (set); Calendar-specific properties are +Method shapes follow RFC 8620 section 3.3 (get), RFC 8620 section 3.4 +(changes), RFC 8620 section 3.5 (set); Calendar-specific properties are defined in the JMAP Calendars specification. """ @@ -46,7 +46,7 @@ def parse_calendar_get(response_args: dict) -> list[JMAPCalendar]: whose method name is ``"Calendar/get"``. Returns: - List of :class:`~calendaring_jmap.objects.calendar.JMAPCalendar` objects. + List of ``JMAPCalendar`` objects. Returns an empty list if ``"list"`` is absent or empty. """ return [JMAPCalendar.from_jmap(item) for item in response_args.get("list", [])] @@ -120,6 +120,6 @@ def parse_calendar_set( """Parse the arguments dict from a ``Calendar/set`` method response. Returns a 6-tuple ``(created, updated, destroyed, not_created, not_updated, not_destroyed)``. - See :func:`calendaring_jmap._methods.parse_set_response` for field semantics. + See ``parse_set_response`` for field semantics. """ return parse_set_response(response_args) diff --git a/src/calendaring_jmap/_methods/contact.py b/src/calendaring_jmap/_methods/contact.py index 1cacda0..9dcbd90 100644 --- a/src/calendaring_jmap/_methods/contact.py +++ b/src/calendaring_jmap/_methods/contact.py @@ -5,8 +5,8 @@ JMAP Contacts method builders and response parsers. Pure functions: no HTTP, no state. AddressBook and ContactCard object -shapes are defined in :rfc:`9610` (JMAP Contacts); ContactCard properties -follow JSContact (:rfc:`9553`). +shapes are defined in RFC 9610 (JMAP Contacts); ContactCard properties +follow JSContact (RFC 9553). """ from __future__ import annotations @@ -25,7 +25,7 @@ def build_address_book_get( ) -> tuple: """Build an ``AddressBook/get`` method call tuple. - A standard ``/get`` per :rfc:`8620#section-5.1` (:rfc:`9610#section-2.1` + A standard ``/get`` per RFC 8620 section 5.1 (RFC 9610 section 2.1 defines no custom arguments for it). Args: @@ -47,7 +47,7 @@ def parse_address_book_get(response_args: dict) -> list[JMAPAddressBook]: whose method name is ``"AddressBook/get"``. Returns: - List of :class:`~calendaring_jmap.objects.contact.JMAPAddressBook`. + List of ``JMAPAddressBook``. Empty if ``"list"`` is absent or empty. """ return [JMAPAddressBook.from_jmap(item) for item in response_args.get("list", [])] @@ -62,9 +62,9 @@ def build_contact_query( ) -> tuple: """Build a ``ContactCard/query`` method call tuple. - A standard ``/query`` per :rfc:`8620#section-5.5`. FilterCondition - properties are defined in :rfc:`9610#section-3.3.1`; see - :meth:`~calendaring_jmap.client.JMAPClient.search_contacts` for the + A standard ``/query`` per RFC 8620 section 5.5. FilterCondition + properties are defined in RFC 9610 section 3.3.1; see + ``JMAPClient.search_contacts`` for the live-verified ``email``/``text`` matching behavior on Cyrus and Stalwart, which differs between the two properties. @@ -97,7 +97,7 @@ def build_contact_get_by_query_result( account_id: str, properties: list[str] | None = None ) -> tuple: """Build a ``ContactCard/get`` call that back-references the ids from - the ``ContactCard/query`` call :func:`build_contact_query` builds. + the ``ContactCard/query`` call ``build_contact_query`` builds. Args: account_id: The JMAP accountId, must match the query call's. @@ -105,7 +105,7 @@ def build_contact_get_by_query_result( Returns: A 3-tuple ``("ContactCard/get", arguments_dict, call_id)``, meant - to be appended after :func:`build_contact_query`'s own return value + to be appended after ``build_contact_query``'s own return value in the same ``methodCalls`` list. """ return build_get_by_query_result( @@ -126,7 +126,7 @@ def parse_contact_get(response_args: dict) -> list[JMAPContact]: whose method name is ``"ContactCard/get"``. Returns: - List of :class:`~calendaring_jmap.objects.contact.JMAPContact`. + List of ``JMAPContact``. Empty if ``"list"`` is absent or empty. """ return [JMAPContact.from_jmap(item) for item in response_args.get("list", [])] diff --git a/src/calendaring_jmap/_methods/event.py b/src/calendaring_jmap/_methods/event.py index e4436c7..cec2622 100644 --- a/src/calendaring_jmap/_methods/event.py +++ b/src/calendaring_jmap/_methods/event.py @@ -8,9 +8,9 @@ tuples that go into a ``methodCalls`` list, and parse the corresponding ``methodResponses`` entries. -Method shapes follow :rfc:`8620#section-3.3` (get), :rfc:`8620#section-3.4` -(changes), :rfc:`8620#section-3.5` (set), :rfc:`8620#section-3.6` (query), -:rfc:`8620#section-3.7` (queryChanges); CalendarEvent-specific properties +Method shapes follow RFC 8620 section 3.3 (get), RFC 8620 section 3.4 +(changes), RFC 8620 section 3.5 (set), RFC 8620 section 3.6 (query), +RFC 8620 section 3.7 (queryChanges); CalendarEvent-specific properties are defined in the JMAP Calendars specification. """ @@ -173,7 +173,7 @@ def build_event_query( def build_event_get_by_query_result(account_id: str, properties: list[str] | None = None) -> tuple: """Build a ``CalendarEvent/get`` call that back-references the ids from - the ``CalendarEvent/query`` call :func:`build_event_query` builds. + the ``CalendarEvent/query`` call ``build_event_query`` builds. Args: account_id: The JMAP accountId, must match the query call's. @@ -181,7 +181,7 @@ def build_event_get_by_query_result(account_id: str, properties: list[str] | Non Returns: A 3-tuple ``("CalendarEvent/get", arguments_dict, call_id)``, meant - to be appended after :func:`build_event_query`'s own return value in + to be appended after ``build_event_query``'s own return value in the same ``methodCalls`` list. """ return build_get_by_query_result( @@ -289,6 +289,6 @@ def parse_event_set( """Parse the arguments dict from a ``CalendarEvent/set`` method response. Returns a 6-tuple ``(created, updated, destroyed, not_created, not_updated, not_destroyed)``. - See :func:`calendaring_jmap._methods.parse_set_response` for field semantics. + See ``parse_set_response`` for field semantics. """ return parse_set_response(response_args) diff --git a/src/calendaring_jmap/_methods/principal.py b/src/calendaring_jmap/_methods/principal.py index bf45a31..ae9c2be 100644 --- a/src/calendaring_jmap/_methods/principal.py +++ b/src/calendaring_jmap/_methods/principal.py @@ -9,7 +9,7 @@ ``methodResponses`` entries. ``Principal/getAvailability`` is defined in draft-ietf-jmap-calendars -section 2.2, layered on the ``Principal`` object from :rfc:`9670` (JMAP +section 2.2, layered on the ``Principal`` object from RFC 9670 (JMAP Sharing). It is not a get/set/query method; it takes its own bespoke arguments and returns a list of ``BusyPeriod`` objects, also defined in that same section. @@ -28,12 +28,12 @@ def build_get_availability( """Build a ``Principal/getAvailability`` method call tuple. Deliberately has no ``account_id`` parameter, unlike every other - ``build_*`` function in this package. Confirmed live against Cyrus: - passing ``accountId`` inside this method's own arguments dict fails - with ``invalidArguments: ["accountId"]``, even with a verified-correct - value, while omitting it (so the server falls back to the - authenticated user's own account) succeeds. Confirmed live against - Stalwart that passing ``accountId`` explicitly works fine there, so + ``build_*`` function in this package. Against a running Cyrus + instance, passing ``accountId`` inside this method's own arguments + dict fails with ``invalidArguments: ["accountId"]``, even with a + verified-correct value, while omitting it (so the server falls back + to the authenticated user's own account) succeeds. Stalwart behaves + differently: passing ``accountId`` explicitly works fine there, so omitting it is the one shape that works on both. Args: @@ -47,10 +47,11 @@ def build_get_availability( string. show_details: If true, populate each returned ``BusyPeriod.event`` with the underlying event, where the caller has ``mayReadItems`` - and the event isn't private. Confirmed live: Stalwart also - requires ``event_properties`` to be set for this to take - effect at all; omitting it returns ``event: null`` on Stalwart - even with ``show_details=True``, unlike Cyrus which returns full + and the event isn't private. Stalwart also requires + ``event_properties`` to be set for this to take effect at + all, found by testing directly against it; omitting it + returns ``event: null`` on Stalwart even with + ``show_details=True``, unlike Cyrus which returns full details by default. event_properties: Which ``CalendarEvent`` properties to include in each returned event, when ``show_details`` is true. Confirmed @@ -79,7 +80,6 @@ def parse_get_availability(response_args: dict) -> list[dict]: List of raw ``BusyPeriod`` dicts, each with ``utcStart``, ``utcEnd``, ``busyStatus``, ``event`` (``None`` unless ``showDetails`` was set and permitted), and ``accountId``. Callers convert these to typed - :class:`~calendaring_jmap.objects.busy_interval.BusyInterval` - objects; this function does not. + ``BusyInterval`` objects; this function does not. """ return response_args.get("list", []) diff --git a/src/calendaring_jmap/_methods/push.py b/src/calendaring_jmap/_methods/push.py index 5b347b1..a4d625f 100644 --- a/src/calendaring_jmap/_methods/push.py +++ b/src/calendaring_jmap/_methods/push.py @@ -5,13 +5,13 @@ JMAP PushSubscription method builders and response parsers. Pure functions: no HTTP, no state. PushSubscription is defined in -:rfc:`8620#section-7.2`, part of JMAP core rather than a capability-gated +RFC 8620 section 7.2, part of JMAP core rather than a capability-gated extension: no capability URN beyond ``urn:ietf:params:jmap:core`` is ever required for these methods. Unlike every other object type in this package, ``PushSubscription/get`` and ``PushSubscription/set`` neither take nor return an ``accountId`` -argument (:rfc:`8620#section-7.2.1`, :rfc:`8620#section-7.2.2`): a push +argument (RFC 8620 section 7.2.1, RFC 8620 section 7.2.2): a push subscription is tied to the credentials that created it, not to a specific account. The ``/set`` builders below (this client has no need for a ``PushSubscription/get`` builder; see the client's own @@ -20,7 +20,7 @@ unlike ``build_get``/``build_query`` in this package's ``__init__.py``, which always include it. -Confirmed live (2026-09) that Cyrus does not implement ``PushSubscription/get`` +As of 2026-09, tested directly: Cyrus doesn't implement ``PushSubscription/get`` or ``/set`` at all (``unknownMethod`` on both). Stalwart implements both correctly, but echoes an ``accountId`` key in every ``/set`` response despite the RFC's text that the method does not return one; @@ -60,7 +60,7 @@ def build_push_subscription_set_create( Args: device_client_id: Client-chosen id identifying the device/app - combination creating this subscription (:rfc:`8620#section-7.2`). + combination creating this subscription (RFC 8620 section 7.2). url: The HTTPS URL the server will POST push notifications to. types: List of type names to receive notifications for, or ``None`` for all types. @@ -122,6 +122,6 @@ def parse_push_subscription_set( """Parse the arguments dict from a ``PushSubscription/set`` method response. Returns a 6-tuple ``(created, updated, destroyed, not_created, not_updated, not_destroyed)``. - See :func:`calendaring_jmap._methods.parse_set_response` for field semantics. + See ``parse_set_response`` for field semantics. """ return parse_set_response(response_args) diff --git a/src/calendaring_jmap/_methods/task.py b/src/calendaring_jmap/_methods/task.py index 6d89857..f7ce2f8 100644 --- a/src/calendaring_jmap/_methods/task.py +++ b/src/calendaring_jmap/_methods/task.py @@ -8,10 +8,10 @@ tuples that go into a ``methodCalls`` list, and parse the corresponding ``methodResponses`` entries. -Method shapes follow :rfc:`8620#section-5.1` (get), :rfc:`8620#section-5.2` -(changes), :rfc:`8620#section-5.3` (set), :rfc:`8620#section-5.5` (query); +Method shapes follow RFC 8620 section 5.1 (get), RFC 8620 section 5.2 +(changes), RFC 8620 section 5.3 (set), RFC 8620 section 5.5 (query); Task-specific properties are defined in draft-ietf-jmap-tasks (built on -:rfc:`8984`). draft-ietf-jmap-tasks section 4.13 never defines a Task-specific +RFC 8984). draft-ietf-jmap-tasks section 4.13 never defines a Task-specific query filter object (a literal author TODO in the draft text), so ``build_task_query`` accepts only whatever filter properties the caller already knows the server accepts; no ``TaskFilter`` shape is assumed here. @@ -130,8 +130,8 @@ def parse_task_changes( Returns: A 6-tuple ``(old_state, new_state, has_more_changes, created, updated, destroyed)``. ``Task/changes`` uses the identical shape as - :func:`calendaring_jmap._methods.event.parse_event_changes` - (draft-ietf-jmap-tasks section 4.10, :rfc:`8620#section-5.2`). + ``parse_event_changes`` + (draft-ietf-jmap-tasks section 4.10, RFC 8620 section 5.2). """ return ( response_args.get("oldState", ""), @@ -156,7 +156,7 @@ def build_task_query( account_id: The JMAP accountId to query. filter_condition: A filter dict. draft-ietf-jmap-tasks section 4.13 never defines a Task-specific filter object; only ``text`` is sent by - :meth:`~calendaring_jmap.client.JMAPClient.search_tasks`, matching + ``search_tasks``, matching what that method actually applies server-side. sort: List of ``Comparator`` dicts, e.g. ``[{"property": "due", "isAscending": True}]``. @@ -174,7 +174,7 @@ def build_task_query( def build_task_get_by_query_result(account_id: str, properties: list[str] | None = None) -> tuple: """Build a ``Task/get`` call that back-references the ids from - the ``Task/query`` call :func:`build_task_query` builds. + the ``Task/query`` call ``build_task_query`` builds. Args: account_id: The JMAP accountId, must match the query call's. @@ -182,7 +182,7 @@ def build_task_get_by_query_result(account_id: str, properties: list[str] | None Returns: A 3-tuple ``("Task/get", arguments_dict, call_id)``, meant - to be appended after :func:`build_task_query`'s own return value in + to be appended after ``build_task_query``'s own return value in the same ``methodCalls`` list. """ return build_get_by_query_result( @@ -264,6 +264,6 @@ def parse_task_set( """Parse the arguments dict from a ``Task/set`` method response. Returns a 6-tuple ``(created, updated, destroyed, not_created, not_updated, not_destroyed)``. - See :func:`calendaring_jmap._methods.parse_set_response` for field semantics. + See ``parse_set_response`` for field semantics. """ return parse_set_response(response_args) diff --git a/src/calendaring_jmap/async_client.py b/src/calendaring_jmap/async_client.py index 4c288c3..a7ad47d 100644 --- a/src/calendaring_jmap/async_client.py +++ b/src/calendaring_jmap/async_client.py @@ -6,7 +6,7 @@ Mirrors JMAPClient with all public methods as coroutines. Uses niquests' AsyncSession for HTTP: the one part of calendaring-jmap with -no fallback to another HTTP library. See :mod:`calendaring_jmap._http`. +no fallback to another HTTP library. See ``calendaring_jmap._http``. All response-parsing logic lives in _JMAPClientBase (client.py); each method here is a ~3-line async wrapper: get session, send request, delegate to parser. @@ -132,8 +132,8 @@ def _get_http_session(self) -> AsyncSession: async def aclose(self) -> None: """Release the persistent HTTP session and its connection pool. - Only needed when the client was not used as an async context manager - -- the documented Quick Start builds one directly. Idempotent; the + Only needed when the client was not used as an async context manager: + the documented Quick Start builds one directly. Idempotent; the session is recreated on the next request. """ if self._http_session is not None: @@ -536,7 +536,7 @@ async def upload_attachment( ) -> str: """Upload binary data as a JMAP blob. - See :meth:`JMAPClient.upload_attachment` for the full semantics. + See :meth:`~calendaring_jmap.client.JMAPClient.upload_attachment` for the full semantics. """ session = await self._get_session() target_account = self._resolve_account(session, account_id) @@ -557,7 +557,7 @@ async def download_attachment( ) -> bytes: """Download a JMAP blob by id. - See :meth:`JMAPClient.download_attachment` for the full semantics. + See :meth:`~calendaring_jmap.client.JMAPClient.download_attachment` for the full semantics. """ session = await self._get_session() target_account = self._resolve_account(session, account_id) @@ -584,7 +584,7 @@ async def attach_to_event( ) -> None: """Attach an uploaded blob to a calendar event. - See :meth:`JMAPClient.attach_to_event` for the full semantics. + See :meth:`~calendaring_jmap.client.JMAPClient.attach_to_event` for the full semantics. """ session = await self._get_session() target_account = self._resolve_account(session, account_id) @@ -605,7 +605,8 @@ async def get_event_attachments( ) -> list[JMAPAttachment]: """Return the attachments on a calendar event. - See :meth:`JMAPClient.get_event_attachments` for the full semantics. + See :meth:`~calendaring_jmap.client.JMAPClient.get_event_attachments` + for the full semantics. """ session = await self._get_session() target_account = self._resolve_account(session, account_id) @@ -621,7 +622,7 @@ async def _find_own_participant_id( """Return the participant id in ``event_id`` whose email is ``own_email``. Only the ``participants`` property is fetched, not the whole event. - See :meth:`JMAPClient._find_participant_id_by_email` for the matching rules. + See ``JMAPClient._find_participant_id_by_email`` for the matching rules. """ session = await self._get_session() target_account = self._resolve_account(session, account_id) @@ -659,7 +660,7 @@ async def accept_invitation( ) -> None: """Accept a meeting invitation, notifying the organizer. - See :meth:`JMAPClient.accept_invitation` for the full semantics. + See :meth:`~calendaring_jmap.client.JMAPClient.accept_invitation` for the full semantics. """ await self._respond_to_invitation( event_id, own_email, PARTICIPATION_STATUS_ACCEPTED, account_id=account_id @@ -777,7 +778,7 @@ async def search_events( ) -> list[JMAPCalendarObject]: """Search for calendar events. - See :meth:`JMAPClient.search_events` for the full semantics, + See :meth:`~calendaring_jmap.client.JMAPClient.search_events` for the full semantics, including why ``has_attachment``/``participant_email``/ ``participation_status``/``participant_role`` filter client-side and their confirmed server-specific false-negatives. @@ -803,7 +804,7 @@ async def get_availability( ) -> dict[str, list[BusyInterval]]: """Return busy intervals for each of your own accounts over a time period. - See :meth:`JMAPClient.get_availability` for the full semantics. + See :meth:`~calendaring_jmap.client.JMAPClient.get_availability` for the full semantics. """ session = await self._get_session() result: dict[str, list[BusyInterval]] = {} @@ -848,7 +849,7 @@ async def _get_availability_via_fallback( async def get_address_books(self, account_id: str | None = None) -> list[JMAPAddressBook]: """Fetch all address books for an account. - See :meth:`JMAPClient.get_address_books` for the full semantics, + See :meth:`~calendaring_jmap.client.JMAPClient.get_address_books` for the full semantics, including the capability-fallback behavior. """ session = await self._get_session() @@ -869,7 +870,7 @@ async def search_contacts( ) -> list[JMAPContact]: """Search for contact cards. - See :meth:`JMAPClient.search_contacts` for the full semantics. + See :meth:`~calendaring_jmap.client.JMAPClient.search_contacts` for the full semantics. """ session = await self._get_session() target_account = self._resolve_account(session, account_id) @@ -885,7 +886,7 @@ async def subscribe_push( ) -> str: """Register a push subscription. - See :meth:`JMAPClient.subscribe_push` for the full semantics, + See :meth:`~calendaring_jmap.client.JMAPClient.subscribe_push` for the full semantics, including why this client cannot complete the verification handshake by itself. """ @@ -899,8 +900,8 @@ async def subscribe_push( ) async def _update_push_subscription(self, subscription_id: str, patch: dict) -> None: - """Shared implementation for :meth:`confirm_push_verification` and - :meth:`renew_push`. See :meth:`JMAPClient._update_push_subscription`.""" + """Shared implementation for ``confirm_push_verification`` and + ``renew_push``. See ``JMAPClient._update_push_subscription``.""" session = await self._get_session() call = build_push_subscription_set_update(subscription_id, patch) responses = await self._request([call], using=_PUSH_USING) @@ -915,7 +916,8 @@ async def _update_push_subscription(self, subscription_id: str, patch: dict) -> async def confirm_push_verification(self, subscription_id: str, verification_code: str) -> None: """Submit the verification code the server POSTed to ``callback_url``. - See :meth:`JMAPClient.confirm_push_verification` for the full semantics. + See :meth:`~calendaring_jmap.client.JMAPClient.confirm_push_verification` + for the full semantics. """ await self._update_push_subscription( subscription_id, {"verificationCode": verification_code} @@ -924,14 +926,14 @@ async def confirm_push_verification(self, subscription_id: str, verification_cod async def renew_push(self, subscription_id: str, expires: str | None = None) -> None: """Extend (or shorten) a push subscription's expiry. - See :meth:`JMAPClient.renew_push` for the full semantics. + See :meth:`~calendaring_jmap.client.JMAPClient.renew_push` for the full semantics. """ await self._update_push_subscription(subscription_id, {"expires": expires}) async def unsubscribe_push(self, subscription_id: str) -> None: """Destroy a push subscription. - See :meth:`JMAPClient.unsubscribe_push` for the full semantics. + See :meth:`~calendaring_jmap.client.JMAPClient.unsubscribe_push` for the full semantics. """ session = await self._get_session() call = build_push_subscription_set_destroy(subscription_id) @@ -1139,8 +1141,8 @@ async def get_task_sync_token(self) -> str: Calls ``Task/get`` with an empty ID list, so no task data is transferred, only the ``state`` field from the response. Reads the - ``Task`` type's own state counter, not ``TaskList``'s: RFC 8620 - section 5.2's ``/changes`` is scoped per type, and + ``Task`` type's own state counter, not ``TaskList``'s: :rfc:`8620#section-5.2`'s + ``/changes`` is scoped per type, and :meth:`get_tasks_by_sync_token` calls ``Task/changes``, which only accepts a state previously returned for ``Task`` itself. @@ -1206,7 +1208,7 @@ async def search_tasks( ) -> list[dict]: """Search for tasks. - See :meth:`JMAPClient.search_tasks` for the full semantics, + See :meth:`~calendaring_jmap.client.JMAPClient.search_tasks` for the full semantics, including why ``due_before``/``due_after``/``progress`` filter client-side and what the ``Task/query``-unsupported fallback can and cannot help with. diff --git a/src/calendaring_jmap/client.py b/src/calendaring_jmap/client.py index d7e70e5..0b81578 100644 --- a/src/calendaring_jmap/client.py +++ b/src/calendaring_jmap/client.py @@ -7,8 +7,8 @@ Wraps session establishment, HTTP communication, and method dispatching into a single object with a clean public API. -Auth note: JMAP has no 401-challenge-retry dance (unlike CalDAV). -Credentials are sent upfront on every request. A 401/403 is a hard failure. +Auth note: JMAP has no 401-challenge-retry dance. Credentials are sent +upfront on every request. A 401/403 is a hard failure. """ from __future__ import annotations @@ -104,7 +104,7 @@ _TASK_USING = [CORE_CAPABILITY, TASK_CAPABILITY] _PRINCIPALS_USING = [CORE_CAPABILITY, CALENDAR_CAPABILITY, PRINCIPALS_CAPABILITY] _CONTACTS_USING = [CORE_CAPABILITY, CONTACTS_CAPABILITY] -#: PushSubscription is part of JMAP core (:rfc:`8620#section-7.2`), not a +#: PushSubscription is part of JMAP core (RFC 8620 section 7.2), not a #: capability-gated extension, so no capability beyond core is ever needed. _PUSH_USING = [CORE_CAPABILITY] @@ -142,7 +142,7 @@ def _build_auth(self, auth_type: str | None): JMAP supports Basic and Bearer auth; Digest is not supported. When ``auth_type`` is ``None`` the type is inferred from the credentials supplied: a username triggers Basic, a password - alone triggers Bearer, and neither raises :class:`JMAPAuthError`. + alone triggers Bearer, and neither raises ``JMAPAuthError``. """ effective_type = auth_type if effective_type is None: @@ -194,13 +194,10 @@ def _account_supports(session: Session, capability: str) -> bool: @staticmethod def _supports_principals(session: Session) -> bool: - """Return whether this account advertises :rfc:`9670` Principal support. + """Return whether this account advertises RFC 9670 Principal support. - This is the real gate for ``Principal/getAvailability``, not the - draft's own narrower ``:availability`` sub-capability: confirmed - live that Cyrus implements the method fully without ever - advertising that sub-capability, so checking for it would wrongly - skip Cyrus every time. + See ``get_availability`` for why this checks the base + capability, not the draft's own narrower sub-capability. """ return _JMAPClientBase._account_supports(session, PRINCIPALS_CAPABILITY) @@ -208,7 +205,7 @@ def _supports_principals(session: Session) -> bool: def _current_user_principal_id(session: Session) -> str | None: """Return the caller's own Principal id from the session, if any. - :rfc:`9670#section-1.5.1`: ``currentUserPrincipalId`` is a property of the + RFC 9670 section 1.5.1: ``currentUserPrincipalId`` is a property of the ``urn:ietf:params:jmap:principals`` entry in ``accountCapabilities``. No ``Principal/query``/``Principal/get`` call is needed for this. """ @@ -271,8 +268,9 @@ def _warn_contacts_unsupported(account_id: str) -> None: def _as_utc_datetime(local_datetime: str) -> str: """Convert one of ``get_availability``'s own UTC-treated ``start``/``end`` strings to the ``UTCDateTime`` format - ``Principal/getAvailability`` requires. Confirmed live that Cyrus - rejects a bare, unsuffixed value here with ``invalidArguments``. + ``Principal/getAvailability`` requires. Cyrus rejects a bare, + unsuffixed value here with ``invalidArguments``, verified against + a running instance. """ return f"{local_datetime}Z" @@ -326,7 +324,7 @@ def _event_matches_search_filters( (draft-ietf-jmap-calendars-29 section 5.11.1 lists exactly ``inCalendar``, ``after``, ``before``, ``text``, ``title``, ``description``, ``location``, ``owner``, ``attendee``, ``uid``), so - unlike :meth:`_build_event_search_calls`'s own filters, these run in + unlike ``_build_event_search_calls``'s own filters, these run in Python against the already-fetched event, not on the server. A ``None`` argument is not checked at all, matching ``FilterCondition``'s own "zero properties specified means the @@ -340,10 +338,10 @@ def _event_matches_search_filters( matches ``participant_email`` and a different participant matches ``participant_role`` still matches both conditions. - Confirmed live that Cyrus drops a Link's ``rel: "enclosure"`` on - read-back, so ``has_attachment=True`` undercounts real attachments - there. Confirmed live that both Cyrus and Stalwart drop the - ``attendee`` role from a participant's ``roles`` map, so + Cyrus drops a Link's ``rel: "enclosure"`` on read-back (seen against + a running instance), so ``has_attachment=True`` undercounts real + attachments there. Both Cyrus and Stalwart drop the ``attendee`` + role from a participant's ``roles`` map, so ``participant_role="attendee"`` undercounts on both servers. """ if has_attachment is not None: @@ -404,14 +402,10 @@ def _build_contact_search_calls( @staticmethod def _jscal_start_to_utc_datetime(start: str, time_zone: str | None) -> str: """Convert a JSCalendar ``start``/``timeZone`` pair to a ``Z``-suffixed - ``UTCDateTime`` string, matching what ``Principal/getAvailability`` - returns, so ``BusyInterval.start``/``.end`` have one consistent - format regardless of which path produced them (:rfc:`8984`'s own three - ``start`` shapes: already ``Z``-suffixed UTC, ``timeZone``-qualified, - or floating/naive with neither, see ``jscal_to_ical._start_to_dtstart``). - A floating start (no ``timeZone``) has no true UTC equivalent; it is - treated as UTC, matching ``get_availability``'s own documented - treatment of its ``start``/``end`` window parameters. + ``UTCDateTime`` string. + + See ``BusyInterval`` for why this normalization exists and how a + floating start is treated. """ if start.endswith("Z"): return start @@ -431,9 +425,8 @@ def _jscal_start_to_utc_datetime(start: str, time_zone: str | None) -> str: def _busy_intervals_from_events(cls, events: list[dict]) -> list[BusyInterval]: """Compute BusyInterval objects from raw event dicts for the fallback path. - :rfc:`8984#section-4.4.2`: ``freeBusyStatus`` is ``"free"`` or - ``"busy"``, default ``"busy"`` when absent. Events marked ``"free"`` - don't count toward busy time. + See ``get_availability`` for the ``freeBusyStatus`` filtering + rule this implements. """ intervals = [] for event in events: @@ -455,13 +448,11 @@ def _busy_intervals_from_events(cls, events: list[dict]) -> list[BusyInterval]: def _build_event_update_patch(ical_str: str) -> tuple[dict, frozenset[str]]: """Build a JSCalendar PatchObject for a ``CalendarEvent/set`` update. - :rfc:`8620#section-5.3` merge semantics preserve properties absent from - the patch, so any optional property removed client-side must be - explicitly nulled to actually clear it server-side. Returns the patch - together with the set - of keys that were null-injected purely for this cleanup (i.e. were not - present in the converted iCalendar) so the caller can drop them if the - server refuses to null a property it does not support. + See ``update_event`` for why absent properties are explicitly + nulled. Returns the patch together with the set of keys that were + null-injected purely for this cleanup (not present in the converted + iCalendar), so the caller can drop them if the server refuses to + null a property it does not support. """ patch = ical_to_jscal(ical_str) patch.pop("uid", None) # uid is server-immutable after creation; patch must omit it @@ -487,7 +478,7 @@ def _unsupported_null_keys( Returns the set of droppable keys when the failure is exactly this case, or ``None`` when the update succeeded or failed for a genuine reason (in - which case the caller proceeds to :meth:`_parse_update_response`, + which case the caller proceeds to ``_parse_update_response``, which raises the real error). Some servers report only one offending property per response, so the caller retries in a loop, dropping the reported keys until the update succeeds or hits a genuine error; each @@ -526,9 +517,9 @@ def _first_matching_list(responses: list, method_name: str, parser) -> list: Shared by every ``_parse_*`` method whose whole job is "find one response by method name, hand its args to a parser, otherwise return an empty list" (``parser`` can itself bind extra state onto - each result, e.g. :meth:`_parse_get_calendars`'s own client/account + each result, e.g. ``_parse_get_calendars``'s own client/account binding). A method that needs to raise instead of returning ``[]`` - (e.g. :meth:`_parse_get_sync_token_response`) doesn't fit this + (e.g. ``_parse_get_sync_token_response``) doesn't fit this shape and isn't a caller. """ for name, resp_args, _ in responses: @@ -564,7 +555,7 @@ def _parse_search_contacts_response(responses: list) -> list[JMAPContact]: def _no_set_response_error(api_url: str, set_method: str) -> JMAPMethodError: """Build the error raised when a batched response never contains the expected ``set_method`` entry at all (as opposed to containing it - with a per-object failure, which goes through :meth:`_raise_set_error` + with a per-object failure, which goes through ``_raise_set_error`` instead). Shared by all three ``_parse_*_response`` methods below.""" return JMAPMethodError(url=api_url, reason=f"No {set_method} response") @@ -633,9 +624,9 @@ def _participant_matches_email(participant: dict, email: str) -> bool: covers a spec-compliant server, or another client's participant, that only ever set ``calendarAddress``. - Shared by :meth:`_find_participant_id_by_email` (which scans a whole + Shared by ``_find_participant_id_by_email`` (which scans a whole ``participants`` dict for the caller's own participant id) and - :meth:`_event_matches_search_filters` (which uses this per-participant + ``_event_matches_search_filters`` (which uses this per-participant check directly as a filter predicate). """ candidate = participant.get("email") or participant.get("calendarAddress") or "" @@ -648,10 +639,10 @@ def _find_participant_id_by_email( ) -> str: """Return the id of the entry in ``participants`` whose email is ``own_email``. - See :meth:`_participant_matches_email` for the matching rule. + See ``_participant_matches_email`` for the matching rule. - Shared by :meth:`JMAPClient._find_own_participant_id` and - :meth:`AsyncJMAPClient._find_own_participant_id`. + Shared by ``JMAPClient._find_own_participant_id`` and + ``AsyncJMAPClient._find_own_participant_id``. Raises: JMAPMethodError: If no participant matches ``own_email``. @@ -739,11 +730,11 @@ def _parse_changes_response( Shared by every ``_parse_*_changes_response`` (CalendarEvent, Task): each one differs only in the method name to match and the parser - function that turns ``resp_args`` into the 6-tuple :rfc:`8620#section-5.2` + function that turns ``resp_args`` into the 6-tuple RFC 8620 section 5.2 defines. Returns ``(created_ids, updated_ids, destroyed_ids, new_sync_token)``. - Raises :class:`JMAPMethodError` when the server truncated the result. + Raises ``JMAPMethodError`` when the server truncated the result. """ created_ids: list[str] = [] updated_ids: list[str] = [] @@ -894,9 +885,9 @@ def _task_matches_search_filters( ``due_before``/``due_after`` have no corresponding Task/query filter property (draft-ietf-jmap-tasks section 4.13 never defines one), so they run in Python against the already-fetched task's ``due`` - (:rfc:`8984#section-5.2.1`, LocalDateTime, lexicographically ordered). + (RFC 8984 section 5.2.1, LocalDateTime, lexicographically ordered). A task with no ``due`` set does not match either when given. - ``progress`` matches :rfc:`8984#section-5.2.5`'s own value, treating + ``progress`` matches RFC 8984 section 5.2.5's own value, treating an absent value as ``"needs-action"``. That section's real default is a derivation from participant ``progress`` values, not an unconditional ``"needs-action"``, but this client never sets a @@ -904,8 +895,8 @@ def _task_matches_search_filters( ``"needs-action"`` for any task this client can create. ``text`` is normally sent server-side via ``Task/query``'s filter - (see :meth:`_build_task_search_calls`) and left ``None`` here, so - this check is skipped; :meth:`_search_tasks_via_fallback` is the + (see ``_build_task_search_calls``) and left ``None`` here, so + this check is skipped; ``_search_tasks_via_fallback`` is the only caller that passes it, since a server with no ``Task/query`` support at all needs every filter, including ``text``, applied client-side. Matches as a case-insensitive substring of ``title``, @@ -934,9 +925,10 @@ def _is_task_query_unsupported_http_error(error: Exception) -> bool: server doesn't support ``Task/query`` (or the tasks capability at all), as opposed to a genuine failure that should propagate. - Confirmed live this project's own test servers reject an - unsupported capability at the request level, not the method level, - so this check (unlike :meth:`_should_fall_back_to_query`'s + Tested against both of this project's own test servers: each + rejects an unsupported capability at the request level, not the + method level, + so this check (unlike ``_should_fall_back_to_query``'s method-level ``error_type`` check) inspects the HTTP error body instead. Cyrus returns ``unknownCapability``; Stalwart returns ``notRequest`` for any capability it doesn't recognize at all @@ -961,15 +953,15 @@ def _is_task_query_unsupported_http_error(error: Exception) -> bool: def _should_fall_back_from_task_query(error: Exception) -> bool: """Return whether ``error``, raised by the primary ``Task/query`` path, means ``search_tasks`` should retry via - :meth:`_search_tasks_via_fallback`, as opposed to propagating. + ``_search_tasks_via_fallback``, as opposed to propagating. Shared by the sync and async ``search_tasks``: both catch ``(requests.HTTPError, JMAPMethodError)`` around the same primary call and need the identical two-way decision, a method-level ``error_type`` check for ``JMAPMethodError`` - (:meth:`_should_fall_back_to_query`, the same one + (``_should_fall_back_to_query``, the same one ``get_availability`` uses) or a request-level body check for - ``requests.HTTPError`` (:meth:`_is_task_query_unsupported_http_error`). + ``requests.HTTPError`` (``_is_task_query_unsupported_http_error``). """ if isinstance(error, JMAPMethodError): return _JMAPClientBase._should_fall_back_to_query(error.error_type) @@ -1032,9 +1024,9 @@ def _build_download_headers() -> dict: def _check_blob_response(response, url: str) -> None: """Raise on an HTTP error from a raw blob upload or download. - Shared by :meth:`upload_attachment`/:meth:`download_attachment` on + Shared by ``upload_attachment``/``download_attachment`` on both the sync and async client. Same 401/403 handling as - :func:`~calendaring_jmap.session.fetch_session`; any other non-2xx + ``fetch_session``; any other non-2xx (e.g. 404 for an unknown blobId, confirmed live on both Cyrus and Stalwart) surfaces as a plain HTTP error, since neither blob call has a JMAP methodResponses envelope to carry a JMAP-style error in. @@ -1059,11 +1051,11 @@ def _build_blob_download_url( ) -> str: """Expand the Session's ``downloadUrl`` template for one blob. - Shared by :meth:`download_attachment`/:meth:`attach_to_event` on + Shared by ``download_attachment``/``attach_to_event`` on both the sync and async client. ``content_type``/``name`` are taken - as given: :meth:`download_attachment` passes ``or ""`` for its own + as given: ``download_attachment`` passes ``or ""`` for its own optional parameters (see its docstring for why that's safe); - :meth:`attach_to_event` always has real values, since both become + ``attach_to_event`` always has real values, since both become properties on the Link it writes. """ download_url = cls._require_blob_url(session.download_url, "downloadUrl", session.api_url) @@ -1116,9 +1108,9 @@ def _get_http_session(self): def close(self) -> None: """Release the persistent HTTP session and its connection pool. - Only needed when the client was not used as a context manager -- the + Only needed when the client was not used as a context manager: the documented Quick Start builds one directly, and without this there - was no way to hand the sockets back. Idempotent; the session is + was no way to hand the sockets back. Idempotent; the session is recreated on the next request. """ if self._http_session is not None: @@ -1540,9 +1532,9 @@ def download_attachment( Args: blob_id: The blob id, as returned by :meth:`upload_attachment`. - content_type: Media type of the blob, if known. Confirmed live - against Cyrus and Stalwart that both look up a blob by - ``blobId`` alone: an omitted or wrong ``content_type``/ + content_type: Media type of the blob, if known. Both Cyrus and + Stalwart look up a blob by ``blobId`` alone, tested + directly against each: an omitted or wrong ``content_type``/ ``filename`` only affects the response's own ``Content-Type``/``Content-Disposition`` headers, not whether the download succeeds. @@ -1669,7 +1661,7 @@ def _find_own_participant_id( JMAP participant ids are server-assigned per event and cannot be derived or guessed, so responding to an invitation requires fetching the live event first. Only the ``participants`` property is fetched, - not the whole event. See :meth:`_find_participant_id_by_email` for + not the whole event. See ``_find_participant_id_by_email`` for the matching rules. Raises: @@ -1765,6 +1757,12 @@ def tentatively_accept( def update_event(self, event_id: str, ical_str: str, account_id: str | None = None) -> None: """Update a calendar event from an iCalendar string. + A property present on the server's own copy but absent from + ``ical_str`` is explicitly cleared, not left alone: :rfc:`8620#section-5.3` + merge semantics mean an omitted property would otherwise be + preserved rather than removed, so this nulls it to actually drop it + server-side. + Args: event_id: The JMAP event ID to update. ical_str: A VCALENDAR string with the updated event data. @@ -1861,9 +1859,9 @@ def search_events( participant matches ``participant_email`` and a different participant matches ``participant_role`` still matches both. - Confirmed live that Cyrus drops a Link's ``rel: "enclosure"`` on - read-back, so ``has_attachment=True`` misses real attachments there. - Confirmed live that both Cyrus and Stalwart drop the ``attendee`` + Cyrus drops a Link's ``rel: "enclosure"`` on read-back, confirmed + against a running instance, so ``has_attachment=True`` misses real + attachments there. Both Cyrus and Stalwart drop the ``attendee`` role from a participant's ``roles`` map, so ``participant_role="attendee"`` misses matches on both servers. @@ -1877,7 +1875,7 @@ def search_events( (:meth:`~calendaring_jmap.objects.attachment.JMAPAttachment.is_attachment`). participant_email: Only events with a participant matching this email (case-insensitive, falling back to ``calendarAddress``; - see :meth:`_participant_matches_email` for the matching + see ``_participant_matches_email`` for the matching rule). Matches against a participant's ``email``/``calendarAddress`` property, never ``participantId`` (a server-assigned id with no email @@ -1895,8 +1893,9 @@ def search_events( List of :class:`~calendaring_jmap.objects.calendar_object.JMAPCalendarObject` instances. ``parent`` is ``None`` on these objects since no :class:`~calendaring_jmap.objects.calendar.JMAPCalendar` is available at - the client level; use :meth:`JMAPCalendar.search` if you need ``parent`` - set. + the client level; use + :meth:`~calendaring_jmap.objects.calendar.JMAPCalendar.search` + if you need ``parent`` set. """ return self._search( calendar_id=calendar_id, @@ -1925,7 +1924,12 @@ def get_availability( ``CalendarEvent/query`` scan of that account's own calendars when the account doesn't advertise :rfc:`9670` Principal support at all, the call fails with ``unknownMethod``/``accountNotSupportedByMethod``, or - ``account_id`` isn't the session's own account. + ``account_id`` isn't the session's own account. The check for Principal + support itself is the base :rfc:`9670#section-1.5.1` Principal + capability, not the draft's own narrower ``:availability`` + sub-capability: confirmed live that Cyrus implements this method fully + without ever advertising that sub-capability, so gating on it would + wrongly skip Cyrus every time. ``Principal/getAvailability`` never carries an explicit ``accountId`` (confirmed live: Cyrus rejects one in this method's own args), so it @@ -1938,6 +1942,12 @@ def get_availability( same limitation :meth:`share_calendar` already has for Principal ids in general). + The fallback path builds each interval from a matching event's own + ``freeBusyStatus`` (:rfc:`8984#section-4.4.2`): an event marked + ``"free"`` is excluded from the result entirely, and an event with + no ``freeBusyStatus`` at all is treated as ``"busy"``, the spec's + own default. + Args: account_ids: JMAP accounts to check, one entry in the result per account. Only the session's own account can use the primary @@ -1947,8 +1957,8 @@ def get_availability( end: End of the period, exclusive (``YYYY-MM-DDTHH:MM:SS``, treated as UTC). show_details: If true, populate each interval's ``event`` where - permitted. Confirmed live: on Stalwart this needs the - account's server to support returning event details at all; + permitted. On Stalwart this needs the account's server to + support returning event details at all, verified directly: omitting ``eventProperties`` there returns ``event: None`` even with ``show_details=True``. On Cyrus, details are returned by default. @@ -2013,7 +2023,7 @@ def get_address_books(self, account_id: str | None = None) -> list[JMAPAddressBo support Contacts in general but a different ``account_id`` lacks it specifically, a genuine error surfaces normally instead, the same as :meth:`search_contacts` always does (see - :meth:`_can_skip_contacts_request` for why only the session's own + ``_can_skip_contacts_request`` for why only the session's own account gets the graceful behavior). Args: @@ -2058,25 +2068,27 @@ def search_contacts( :rfc:`9610` Contacts support raises normally here, not an empty list: this method's whole purpose is resolving contact information for something the caller is about to act on (e.g. an invitation), - so silently returning nothing risks going unnoticed. Confirmed live - that the actual error is either a request-level ``requests.HTTPError`` - (HTTP 400, ``unknownCapability``) if the server has no Contacts - support at all, or a method-level ``JMAPMethodError`` with - ``error_type`` ``"accountNotSupportedByMethod"`` if the server - supports Contacts but this particular account doesn't. + so silently returning nothing risks going unnoticed. Tested + directly, the actual error is either a request-level + ``requests.HTTPError`` (HTTP 400, ``unknownCapability``) if the + server has no Contacts support at all, or a method-level + ``JMAPMethodError`` with ``error_type`` + ``"accountNotSupportedByMethod"`` if the server supports Contacts + but this particular account doesn't. Args: - text: Free-text search across the whole card. Confirmed live - that Cyrus matches this as a substring against the name, not - exact-only. Confirmed live that Stalwart v0.16.21 does not - match this against the card's name at all, only against the - email address (the same reach ``email`` already has there): - a name-only search that works on Cyrus can silently return - nothing on Stalwart. Use ``email`` when the value being - searched for might be an email address. - email: Match against any address in the card's ``emails``. - Confirmed live that both Cyrus and Stalwart match this as a - substring too, e.g. ``"alice"`` matches + text: Free-text search across the whole card. Against a running + Cyrus instance, this matches as a substring against the + name, not exact-only. Stalwart v0.16.21 behaves + differently: it doesn't match against the card's name at + all, only against the email address (the same reach + ``email`` already has there), so a name-only search that + works on Cyrus can silently return nothing on Stalwart. Use + ``email`` when the value being searched for might be an + email address. + email: Match against any address in the card's ``emails``. Both + Cyrus and Stalwart match this as a substring too, verified + directly, e.g. ``"alice"`` matches ``"alice@example.com"``. account_id: Pass a different account here to search an address book shared with you. @@ -2129,7 +2141,7 @@ def subscribe_push( combination creating this subscription, so the caller can recognize its own subscriptions later (e.g. to renew or unsubscribe them) even after losing other local state. - Confirmed live that neither test server generates or + Tested against both: neither test server generates or substitutes one, so a fixed value across every install of a library or app would collide across devices; the RFC's own text requires this to differ per device and per @@ -2146,9 +2158,9 @@ def subscribe_push( Raises: JMAPMethodError: If the server rejects the create request, or - does not implement PushSubscription at all. Confirmed live - that Cyrus does not implement ``PushSubscription/set`` - (``unknownMethod``); Stalwart does. + does not implement PushSubscription at all. Cyrus doesn't + implement ``PushSubscription/set`` (``unknownMethod``), + confirmed against a running instance; Stalwart does. """ if types is None: types = ["CalendarEvent"] @@ -2160,8 +2172,8 @@ def subscribe_push( ) def _update_push_subscription(self, subscription_id: str, patch: dict) -> None: - """Shared implementation for :meth:`confirm_push_verification` and - :meth:`renew_push`: both are a single-field ``PushSubscription/set`` + """Shared implementation for ``confirm_push_verification`` and + ``renew_push``: both are a single-field ``PushSubscription/set`` update, differing only in which field they patch.""" session = self._get_session() call = build_push_subscription_set_update(subscription_id, patch) @@ -2180,9 +2192,9 @@ def confirm_push_verification(self, subscription_id: str, verification_code: str Required to complete :meth:`subscribe_push`'s handshake: the server "MUST NOT make any further requests to the URL" until this is called with the matching code (:rfc:`8620#section-7.2.2`). - Confirmed live that Stalwart rejects a wrong code with - ``invalidProperties`` rather than silently ignoring it. See - :meth:`subscribe_push` for the ``account_id`` parameter note. + Stalwart rejects a wrong code with ``invalidProperties`` rather + than silently ignoring it, verified against a running instance. + See :meth:`subscribe_push` for the ``account_id`` parameter note. Args: subscription_id: The JMAP PushSubscription ID returned by @@ -2436,8 +2448,8 @@ def get_task_sync_token(self) -> str: Calls ``Task/get`` with an empty ID list, so no task data is transferred, only the ``state`` field from the response. Reads the - ``Task`` type's own state counter, not ``TaskList``'s: RFC 8620 - section 5.2's ``/changes`` is scoped per type, and + ``Task`` type's own state counter, not ``TaskList``'s: :rfc:`8620#section-5.2`'s + ``/changes`` is scoped per type, and :meth:`get_tasks_by_sync_token` calls ``Task/changes``, which only accepts a state previously returned for ``Task`` itself. @@ -2509,9 +2521,10 @@ def search_tasks( the same fallback shape :meth:`get_availability` already uses for ``Principal/getAvailability``. - Confirmed live this fallback cannot help against either Cyrus or - Stalwart today: both reject the entire ``urn:ietf:params:jmap:tasks`` - capability, not ``Task/query`` specifically, so the fallback's own + Neither server makes this fallback worth reaching today, tested + directly against both: Cyrus and Stalwart reject the entire + ``urn:ietf:params:jmap:tasks`` capability, not ``Task/query`` + specifically, so the fallback's own ``Task/get`` call (which needs that same capability) fails identically and the error still propagates. It only helps a future server that implements the tasks capability well enough for @@ -2529,7 +2542,7 @@ def search_tasks( text: Free-text search. Sent server-side via ``Task/query`` on the primary path; matched as a case-insensitive substring of ``title`` on the fallback path (see - :meth:`_task_matches_search_filters`). + ``_task_matches_search_filters``). due_before: Only tasks whose ``due`` sorts before this value (:rfc:`8984#section-5.2.1` LocalDateTime, ``YYYY-MM-DDTHH:MM:SS``). A task with no ``due`` set never diff --git a/src/calendaring_jmap/constants.py b/src/calendaring_jmap/constants.py index 7ef9d23..983bd3f 100644 --- a/src/calendaring_jmap/constants.py +++ b/src/calendaring_jmap/constants.py @@ -28,11 +28,11 @@ PRINCIPALS_CAPABILITY = "urn:ietf:params:jmap:principals" #: JMAP Contacts capability (:rfc:`9610`). Required to use AddressBook/ContactCard -#: methods. Confirmed live that both Cyrus and Stalwart advertise this capability, +#: methods. Both Cyrus and Stalwart advertise this capability, verified directly, #: under the same account already used for calendars on both servers. CONTACTS_CAPABILITY = "urn:ietf:params:jmap:contacts" -# Participant.participationStatus values (:rfc:`8984#section-4.4.6`). +# Participant.participationStatus values (RFC 8984 section 4.4.6). # Default is PARTICIPATION_STATUS_NEEDS_ACTION when the property is absent. PARTICIPATION_STATUS_NEEDS_ACTION = "needs-action" PARTICIPATION_STATUS_ACCEPTED = "accepted" @@ -48,8 +48,8 @@ BUSY_STATUS_UNAVAILABLE = "unavailable" #: Link.rel value (:rfc:`8984#section-1.4.11`) marking a Link as an -#: attachment. The "rel" property itself is defined in RFC 8984, but this -#: specific value originates in the IANA Link Relations registry's +#: attachment. The "rel" property itself is defined in that same section, +#: but this specific value originates in the IANA Link Relations registry's #: original seed list, :rfc:`4287#section-4.2.7.2` (Atom), not RFC 8984 #: itself. LINK_REL_ENCLOSURE = "enclosure" diff --git a/src/calendaring_jmap/convert/__init__.py b/src/calendaring_jmap/convert/__init__.py index d361034..1d8e0d7 100644 --- a/src/calendaring_jmap/convert/__init__.py +++ b/src/calendaring_jmap/convert/__init__.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later """ -JSCalendar ↔ iCalendar conversion utilities. +JSCalendar and iCalendar conversion utilities. Public API: ical_to_jscal(ical_str, calendar_id=None) -> dict diff --git a/src/calendaring_jmap/convert/_fixup.py b/src/calendaring_jmap/convert/_fixup.py index 31dafce..2d83419 100644 --- a/src/calendaring_jmap/convert/_fixup.py +++ b/src/calendaring_jmap/convert/_fixup.py @@ -1,14 +1,12 @@ # SPDX-FileCopyrightText: 2026 calendaring-jmap contributors # SPDX-License-Identifier: AGPL-3.0-or-later -"""Fixups for iCalendar data that doesn't fully comply with :rfc:`5545`. +"""Fixups for iCalendar data that doesn't fully comply with RFC 5545. -Servers of any protocol, JMAP or CalDAV, can hand back iCalendar that -doesn't quite follow the spec: a missing DTSTAMP, a COMPLETED given as a -date instead of a datetime, a duplicated DTSTAMP, trailing whitespace that -breaks folding. None of this is protocol-specific; it's a property of the -calendar data itself, so calendaring-jmap fixes it up the same way -regardless of which protocol delivered it. +A JMAP server can hand back iCalendar that doesn't quite follow the spec: +a missing DTSTAMP, a COMPLETED given as a date instead of a datetime, a +duplicated DTSTAMP, trailing whitespace that breaks folding. This fixes +up that data before it reaches a caller. All logic here works on the ical string, not on parsed icalendar objects, since broken data can cause the parse itself to fail. @@ -66,7 +64,7 @@ def __call__(self, line: str) -> bool: def fixup(event: str | bytes) -> str: - """Fix up iCalendar data that doesn't fully comply with :rfc:`5545`. + """Fix up iCalendar data that doesn't fully comply with RFC 5545. Applies, in order: @@ -75,13 +73,13 @@ def fixup(event: str | bytes) -> str: 2. A ``CREATED`` timestamp at the epoch of year 1 (rather than absent) is moved to the Unix epoch instead, since some parsers choke on dates that old. - 3. A backslash-escaped ``'`` or ``"`` is unescaped. :rfc:`5545#section-3.3.11`'s + 3. A backslash-escaped ``'`` or ``"`` is unescaped. RFC 5545 section 3.3.11's ``ESCAPED-CHAR`` grammar only covers a literal backslash, semicolon, comma, and newline; quote characters need no escaping at all (``DQUOTE`` is a directly-permitted character in the ``text`` rule itself), but some producers escape them anyway. 4. Trailing whitespace is stripped, except where it's part of a folded - continuation (:rfc:`5545#section-3.1` folds at 75 octets, which can + continuation (RFC 5545 section 3.1 folds at 75 octets, which can land right after a space that belongs to the value). 5. A missing ``DTSTAMP`` (mandatory per the RFC) is added, generated at fixup time. diff --git a/src/calendaring_jmap/convert/_patch.py b/src/calendaring_jmap/convert/_patch.py index a40fc97..a42728b 100644 --- a/src/calendaring_jmap/convert/_patch.py +++ b/src/calendaring_jmap/convert/_patch.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later """ -:rfc:`8620#section-5.3` PatchObject helpers for CalendarEvent/set update calls. +RFC 8620 section 5.3 PatchObject helpers for CalendarEvent/set update calls. When updating an event, absent keys preserve the server's current value. To delete an optional property the patch must set it to null explicitly. diff --git a/src/calendaring_jmap/convert/_utils.py b/src/calendaring_jmap/convert/_utils.py index faed77d..b3e0fe1 100644 --- a/src/calendaring_jmap/convert/_utils.py +++ b/src/calendaring_jmap/convert/_utils.py @@ -120,12 +120,12 @@ def _duration_to_timedelta(duration_str: str) -> timedelta: def _format_local_dt(dt: datetime | date, tzinfo: tzinfo_t | None = None) -> str: """Format a datetime or date as a JSCalendar LocalDateTime string. - :rfc:`8984#section-1.4.5` requires LocalDateTime (no Z suffix) for + RFC 8984 section 1.4.5 requires LocalDateTime (no Z suffix) for override keys and RRULE ``until`` values, and those are expressed in the *event's* timezone. An aware datetime is therefore converted into ``tzinfo`` before the offset is dropped; merely stripping it would shift the value by the UTC offset, and a floating ``UNTIL`` against a TZID ``DTSTART`` - is forbidden outright by :rfc:`5545#section-3.3.10`. + is forbidden outright by RFC 5545 section 3.3.10. ``tzinfo`` is the event's timezone, normally ``DTSTART.dt.tzinfo``. When it is None the event is floating or all-day: there is nothing to convert diff --git a/src/calendaring_jmap/convert/ical_to_jscal.py b/src/calendaring_jmap/convert/ical_to_jscal.py index d87e3de..92a79bd 100644 --- a/src/calendaring_jmap/convert/ical_to_jscal.py +++ b/src/calendaring_jmap/convert/ical_to_jscal.py @@ -98,7 +98,7 @@ def _prop_date_or_datetime(prop) -> datetime | date: def _prop_timedelta(prop) -> timedelta: """Return a vDDDTypes property's ``.dt``, narrowed to ``timedelta``. - See :func:`_prop_date_or_datetime` for why ``getattr`` is used here. + See ``_prop_date_or_datetime`` for why ``getattr`` is used here. """ dt = getattr(prop, "dt", None) if not isinstance(dt, timedelta): @@ -238,7 +238,7 @@ def _first_recurrence_rule(master: dict, ical_key: str, tzinfo=None) -> dict | N def _exdate_to_overrides(exdate_prop, tzinfo=None) -> dict: """Convert an EXDATE property (single or list) to recurrenceOverrides entries. - ``tzinfo`` is the event's timezone; see :func:`_format_local_dt`. + ``tzinfo`` is the event's timezone; see ``_format_local_dt``. Returns: Dict mapping LocalDateTime/UTCDateTime string to {"excluded": True} @@ -259,10 +259,10 @@ def _exdate_to_overrides(exdate_prop, tzinfo=None) -> dict: def _cal_address_to_imip_and_email(addr: str) -> tuple[str, str | None]: """Split a CAL-ADDRESS value into its ``calendarAddress`` URI and, if it is one, its bare email. - ORGANIZER/ATTENDEE values are URIs (:rfc:`5545#section-3.3.3`) and are + ORGANIZER/ATTENDEE values are URIs (RFC 5545 section 3.3.3) and are not required to use the ``mailto:`` scheme (e.g. ``sip:alice@example.com``). - :rfc:`8984#section-4.4.6`'s Participant ``email`` property is specifically - an :rfc:`5322#section-3.4.1` addr-spec, not an arbitrary URI, so a + RFC 8984 section 4.4.6's Participant ``email`` property is specifically + an RFC 5322 section 3.4.1 addr-spec, not an arbitrary URI, so a non-mailto address is kept as-is for ``calendarAddress`` (not double-wrapped in a spurious ``mailto:``) and ``email`` is left unset rather than populated with a value that isn't actually an email address. @@ -344,7 +344,7 @@ def _component_to_participants(component) -> dict: own ``ORGANIZER`` and ``ATTENDEE`` properties. Shared by the master event and each recurrence override's child VEVENT - in :func:`ical_to_jscal`, since both need the identical "one ORGANIZER + in ``ical_to_jscal``, since both need the identical "one ORGANIZER plus zero or more ATTENDEEs" shape built the same way. """ participants: dict = {} @@ -362,16 +362,16 @@ def _valarm_to_alert(alarm) -> tuple[str, dict] | None: """Convert a VALARM component to a (alert_id, Alert dict) tuple. ``trigger`` is an OffsetTrigger or AbsoluteTrigger object, not a bare - string (:rfc:`8984#section-4.5.2`): ``{"@type": "OffsetTrigger", + string (RFC 8984 section 4.5.2): ``{"@type": "OffsetTrigger", "offset": "-PT15M", "relativeTo": "start"}`` for a relative VALARM TRIGGER, or ``{"@type": "AbsoluteTrigger", "when": "..."}`` for an absolute one. Returns ``None`` if the VALARM has no ``TRIGGER``: mandatory per - :rfc:`5545#section-3.6.6` and :rfc:`8984#section-4.5.2` alike, but not + RFC 5545 section 3.6.6 and RFC 8984 section 4.5.2 alike, but not every producer enforces it. Skipping just this one alarm (with a warning) rather than raising means one malformed VALARM doesn't fail - conversion of the whole event, matching :func:`_first_recurrence_rule`'s + conversion of the whole event, matching ``_first_recurrence_rule``'s same "degrade gracefully on a non-fatal sub-item issue" precedent. """ trigger_prop = alarm.get("TRIGGER") @@ -410,7 +410,7 @@ def _component_to_alerts(component) -> dict: ``VALARM`` subcomponents. Shared by the master event and each recurrence override's child VEVENT - in :func:`ical_to_jscal`, since both need the identical "convert every + in ``ical_to_jscal``, since both need the identical "convert every VALARM, skipping ones with no TRIGGER" shape built the same way. """ alarms = [c for c in component.subcomponents if getattr(c, "name", None) == "VALARM"] @@ -430,7 +430,7 @@ def _attach_to_link(attach) -> tuple[str, dict]: ATTACH (``icalendar.vUri``) converts to ``href`` as-is; a binary-form ATTACH (``icalendar.vBinary``, RFC 5545 section 3.8.1.1's inline ``ENCODING=BASE64;VALUE=BINARY`` form) converts to a ``data:`` URL - (:rfc:`2397`), with ``FMTTYPE`` (if set) becoming both the data URL's + (RFC 2397), with ``FMTTYPE`` (if set) becoming both the data URL's media type and ``contentType``. ``rel`` is always set to ``"enclosure"`` (``constants.LINK_REL_ENCLOSURE``): unlike JSCalendar's general Link object, RFC 5545's ATTACH has no notion of a link that isn't an attachment, so @@ -456,11 +456,11 @@ def _attach_to_link(attach) -> tuple[str, dict]: def _url_to_link(url) -> tuple[str, dict]: """Convert a URL property to a (link_id, Link dict) tuple. - Per :rfc:`5545#section-3.8.4.6`, URL has no defined relationship to the - links :rfc:`8984#section-4.2.7` names (enclosure, describedby, icon), so + Per RFC 5545 section 3.8.4.6, URL has no defined relationship to the + links RFC 8984 section 4.2.7 names (enclosure, describedby, icon), so rel is left unset, matching the Link object's own optional rel - (:rfc:`8984#section-1.4.11`). Narrower than the draft's full Link - mapping, same precedent as :func:`_attach_to_link`. + (RFC 8984 section 1.4.11). Narrower than the draft's full Link + mapping, same precedent as ``_attach_to_link``. """ link_id = str(uuid.uuid4()) return link_id, {"@type": "Link", "href": str(url)} @@ -479,19 +479,19 @@ def _conference_to_virtual_location(conference) -> tuple[str, dict]: """Convert a CONFERENCE property to a (virtual_location_id, VirtualLocation dict) tuple. Per draft-ietf-calext-jscalendar-icalendar section 2.3.10: the property - value converts to ``uri`` (mandatory per :rfc:`8984#section-4.2.6`), the - ``LABEL`` parameter (:rfc:`7986#section-6.4`) to ``name``, and the - ``FEATURE`` parameter (:rfc:`7986#section-6.3`, comma-separated when + value converts to ``uri`` (mandatory per RFC 8984 section 4.2.6), the + ``LABEL`` parameter (RFC 7986 section 6.4) to ``name``, and the + ``FEATURE`` parameter (RFC 7986 section 6.3, comma-separated when more than one value is present) to a ``features`` map of lowercased feature names to ``True``. ``icalendar`` returns a bare string for ``FEATURE`` when exactly one value is present and a list for more than - one, the same single-vs-list inconsistency :func:`_as_list` already + one, the same single-vs-list inconsistency ``_as_list`` already normalizes for ``ATTENDEE``/``ATTACH``. ``VirtualLocation``'s ``description`` property is out of scope: the draft (section 3.7) only converts it from a ``VCONFERENCE`` component's own ``DESCRIPTION``/``STYLED-DESCRIPTION``, a separate component this - converter does not read or emit, matching :func:`_attach_to_link`'s own + converter does not read or emit, matching ``_attach_to_link``'s own narrower-than-the-draft precedent. """ virtual_location_id = str(uuid.uuid4()) @@ -563,7 +563,7 @@ def _component_to_virtual_locations(component) -> dict: subcomponents. Shared by the master event and each recurrence override's child VEVENT - in :func:`ical_to_jscal`, since both need the identical "convert every + in ``ical_to_jscal``, since both need the identical "convert every CONFERENCE property, then attach any matching VCONFERENCE description" shape built the same way. """ @@ -618,7 +618,7 @@ def _map_values_differ(child_map: dict, master_map: dict) -> bool: would always report a difference even when nothing actually changed. Comparing by sorted canonical JSON of the values also tolerates the two maps having their entries in a different order, which can happen on a - round trip through :func:`~calendaring_jmap.convert.jscal_to_ical.jscal_to_ical` + round trip through ``jscal_to_ical`` without the content having actually changed. """ diff --git a/src/calendaring_jmap/convert/jscal_to_ical.py b/src/calendaring_jmap/convert/jscal_to_ical.py index aae77f3..d504709 100644 --- a/src/calendaring_jmap/convert/jscal_to_ical.py +++ b/src/calendaring_jmap/convert/jscal_to_ical.py @@ -41,8 +41,9 @@ # RFC 8984 section 1.4.9 (PatchObject): a pointer like "keywords/urgent" sets # or (if null) removes just that one key of the map at "keywords", relative -# to whatever the patched object already has there. Confirmed live: Cyrus -# returns a recurrenceOverrides patch in this flattened, per-key form when +# to whatever the patched object already has there. Observed against a running +# Cyrus instance: it returns a recurrenceOverrides patch in this flattened, +# per-key form when # the master event already has its own value for that property (there is # something to diff against); when the master has none, Cyrus sends the # override's whole map under the literal key instead, since there is @@ -71,7 +72,7 @@ def _resolve_flattened_map_patches(patch: dict, jscal: dict) -> dict: No-op (returns ``patch`` unchanged, not a copy) when ``patch`` has no flattened keys, which is the common case for a patch this converter - produced itself (:func:`~calendaring_jmap.convert.ical_to_jscal.ical_to_jscal` + produced itself (``ical_to_jscal`` only ever emits whole-map replacements, never per-key pointers). Only resolves one level of nesting (``"X/subkey"``), matching what @@ -105,7 +106,7 @@ def _resolve_flattened_map_patches(patch: dict, jscal: dict) -> dict: def _add_status(component, status: str | None) -> None: """Add a ``STATUS`` property to ``component`` from a JSCalendar ``status`` value, if it maps to one. Shared by the master event and - each recurrence override's child VEVENT in :func:`jscal_to_ical`.""" + each recurrence override's child VEVENT in ``jscal_to_ical``.""" if status: ical_status = _STATUS_JSCAL_TO_ICAL.get(status) if ical_status: @@ -116,7 +117,7 @@ def _add_free_busy_status(component, free_busy: str | None) -> None: """Add a ``TRANSP`` property to ``component`` from a JSCalendar ``freeBusyStatus`` value, if it resolves to something other than the default ``OPAQUE``. Shared by the master event and each recurrence - override's child VEVENT in :func:`jscal_to_ical`.""" + override's child VEVENT in ``jscal_to_ical``.""" transp = _FREE_BUSY_TO_TRANSP.get(free_busy, "OPAQUE") if free_busy else "OPAQUE" if transp != "OPAQUE": component.add("transp", transp) @@ -125,7 +126,7 @@ def _add_free_busy_status(component, free_busy: str | None) -> None: def _add_keywords(component, keywords: dict | None) -> None: """Add a ``CATEGORIES`` property to ``component`` from a JSCalendar ``keywords`` map, if non-empty. Shared by the master event and each - recurrence override's child VEVENT in :func:`jscal_to_ical`.""" + recurrence override's child VEVENT in ``jscal_to_ical``.""" keywords = keywords or {} if keywords: cats = _keywords_to_categories(keywords) @@ -137,7 +138,7 @@ def _add_location(component, locations: dict | None) -> None: """Add a ``LOCATION`` property to ``component`` from a JSCalendar ``locations`` map, if it resolves to a name. Shared by the master event and each recurrence override's child VEVENT in - :func:`jscal_to_ical`.""" + ``jscal_to_ical``.""" locations = locations or {} if locations: loc_name = _locations_to_location(locations) @@ -150,9 +151,9 @@ def _virtual_location_to_conference(vloc: dict): JSCalendar VirtualLocation dict. Per draft-ietf-calext-jscalendar-icalendar section 3.7: ``uri`` - (mandatory per :rfc:`8984#section-4.2.6`) converts to the property - value, ``name`` to the ``LABEL`` parameter (:rfc:`7986#section-6.4`), - and ``features`` to the ``FEATURE`` parameter (:rfc:`7986#section-6.3`) + (mandatory per RFC 8984 section 4.2.6) converts to the property + value, ``name`` to the ``LABEL`` parameter (RFC 7986 section 6.4), + and ``features`` to the ``FEATURE`` parameter (RFC 7986 section 6.3) as a list of uppercased names, matching the enum values RFC 7986 itself defines in all caps; ``icalendar`` itself comma-joins a list-valued parameter when serializing. @@ -160,7 +161,7 @@ def _virtual_location_to_conference(vloc: dict): ``description``/``descriptionContentType`` are handled separately: the draft converts them to a ``VCONFERENCE`` component's own ``DESCRIPTION``/``STYLED-DESCRIPTION``, not a ``CONFERENCE`` parameter, - so :func:`_description_to_vconference` builds that sibling component. + so ``_description_to_vconference`` builds that sibling component. """ uri = vloc.get("uri", "") conf = icalendar.vUri(uri) @@ -203,7 +204,7 @@ def _add_virtual_locations_to_component(component, virtual_locations: dict) -> N JSCalendar virtualLocations map, plus a sibling ``VCONFERENCE`` component when an entry has ``description`` set. Shared by the master event and each recurrence override's child VEVENT in - :func:`jscal_to_ical`.""" + ``jscal_to_ical``.""" for vloc in virtual_locations.values(): if vloc.get("uri"): component.add("conference", _virtual_location_to_conference(vloc)) @@ -287,7 +288,7 @@ def _start_to_dtstart( def _recurrence_rules(jscal: dict, singular_key: str, plural_key: str) -> list[dict]: """Return the RecurrenceRule dicts for ``singular_key``/``plural_key`` on an Event. - :rfc:`8984#section-4.3.3` defines the plural key as an array; both test + RFC 8984 section 4.3.3 defines the plural key as an array; both test servers this repo targets send the singular key instead (see the matching comment in ical_to_jscal.py). Prefer the plural array when present, since it is the actual spec type and can hold more than one rule; fall back to the @@ -307,7 +308,7 @@ def _jscal_rrule_to_rrule(rule: dict, time_zone: str | None = None) -> dict: Returns a plain dict suitable for icalendar.vRecur. ``time_zone`` is the event's IANA time zone. The JSCalendar ``until`` is a - LocalDateTime in that zone; :rfc:`5545#section-3.3.10` requires the + LocalDateTime in that zone; RFC 5545 section 3.3.10 requires the iCalendar UNTIL to be UTC whenever DTSTART is a TZID or UTC date-time, so a non-Z ``until`` is converted back to UTC here. """ @@ -466,9 +467,9 @@ def _add_participants_to_component(component, participants: dict) -> None: or child VEVENT) from a JSCalendar participants map. Shared by the master event and each recurrence override's child VEVENT - in :func:`jscal_to_ical`: at most one ``ORGANIZER`` is added (the first + in ``jscal_to_ical``: at most one ``ORGANIZER`` is added (the first participant whose role resolves to one via - :func:`_participant_to_organizer`), and every participant with an + ``_participant_to_organizer``), and every participant with an attendee-shaped role becomes an ``ATTENDEE``. """ organizer_added = False @@ -486,7 +487,7 @@ def _alert_to_valarm(alert: dict) -> icalendar.Alarm: """Convert a JSCalendar Alert dict to an icalendar.Alarm component. ``trigger`` is an OffsetTrigger or AbsoluteTrigger object, not a bare - string (:rfc:`8984#section-4.5.2`): ``{"@type": "OffsetTrigger", + string (RFC 8984 section 4.5.2): ``{"@type": "OffsetTrigger", "offset": "-PT15M", "relativeTo": "start"}`` or ``{"@type": "AbsoluteTrigger", "when": "..."}``. """ @@ -528,7 +529,7 @@ def _alert_to_valarm(alert: dict) -> icalendar.Alarm: def _add_alerts_to_component(component, alerts: dict) -> None: """Add a ``VALARM`` subcomponent to ``component`` for every entry in a JSCalendar alerts map. Shared by the master event and each recurrence - override's child VEVENT in :func:`jscal_to_ical`.""" + override's child VEVENT in ``jscal_to_ical``.""" for alert in alerts.values(): alarm = _alert_to_valarm(alert) component.add_component(alarm) @@ -539,20 +540,18 @@ def _link_to_attach(link: dict): Only converts a Link whose ``rel`` is ``"enclosure"`` (``constants.LINK_REL_ENCLOSURE``); other ``rel`` values are left alone - (not emitted as ATTACH, IMAGE, or LINK), matching - :func:`~calendaring_jmap.convert.ical_to_jscal._attach_to_link`'s + (not emitted as ATTACH, IMAGE, or LINK), matching ``_attach_to_link``'s narrower-than-the-draft scope in the other direction. Returns ``None`` for a Link this function doesn't convert. A ``data:`` URL ``href`` (RFC 2397, produced by the same function for a binary-form ATTACH) converts back to inline ``ENCODING=BASE64; - VALUE=BINARY`` using the stdlib's own RFC 2397 support - (:func:`urllib.request.urlopen`), rather than re-implementing data URL - parsing by hand. Any other ``href`` converts to a plain URI-form ATTACH. + VALUE=BINARY`` using the stdlib's own RFC 2397 support (``urlopen``), + rather than re-implementing data URL parsing by hand. Any other + ``href`` converts to a plain URI-form ATTACH. - See :meth:`JMAPAttachment.is_attachment - ` for - a known Cyrus limitation affecting the ``rel`` this function filters on. + See ``JMAPAttachment.is_attachment`` for a known Cyrus limitation + affecting the ``rel`` this function filters on. """ if link.get("rel") != LINK_REL_ENCLOSURE: return None @@ -579,8 +578,8 @@ def _link_to_url(link: dict): """Convert a JSCalendar Link dict to an icalendar URL property value. Only converts a Link with no ``rel`` set at all; a Link with any - ``rel`` (including ``"enclosure"``) is left to :func:`_link_to_attach` - or a future rel-specific emitter, matching :func:`_link_to_attach`'s + ``rel`` (including ``"enclosure"``) is left to ``_link_to_attach`` + or a future rel-specific emitter, matching ``_link_to_attach``'s own narrower-than-the-draft precedent. """ if link.get("rel"): @@ -616,7 +615,7 @@ def jscal_to_ical(jscal: dict) -> str: jscal: A raw JSCalendar CalendarEvent dict as returned by ``CalendarEvent/get``. Returns: - An iCalendar VCALENDAR string, normalised by :func:`~calendaring_jmap.convert._fixup.fixup`. + An iCalendar VCALENDAR string, normalised by ``fixup``. Raises: ValueError: If ``uid`` is absent or empty (mandatory per diff --git a/src/calendaring_jmap/error.py b/src/calendaring_jmap/error.py index 01854f8..0a11084 100644 --- a/src/calendaring_jmap/error.py +++ b/src/calendaring_jmap/error.py @@ -15,7 +15,7 @@ from __future__ import annotations #: Fallback ``error_type`` used when a server's error response omits its -#: own ``"type"`` field. Not itself a real :rfc:`8620#section-3.6.2` error type, just +#: own ``"type"`` field. Not itself a real RFC 8620 section 3.6.2 error type, just #: this package's own default for a nonconformant response. _DEFAULT_ERROR_TYPE = "serverFail" @@ -97,8 +97,8 @@ class JMAPCapabilityError(JMAPError): class JMAPAuthError(_CaldavAuthorizationError, JMAPError): """HTTP 401 or 403 received from a JMAP server. - Unlike CalDAV, JMAP does not use a 401-challenge-retry dance. - A 401/403 on the session GET or any API call is a hard failure. + JMAP does not use a 401-challenge-retry dance. A 401/403 on the + session GET or any API call is a hard failure. """ error_type = "forbidden" diff --git a/src/calendaring_jmap/objects/busy_interval.py b/src/calendaring_jmap/objects/busy_interval.py index 9d93409..63bbbf3 100644 --- a/src/calendaring_jmap/objects/busy_interval.py +++ b/src/calendaring_jmap/objects/busy_interval.py @@ -24,8 +24,15 @@ class BusyInterval: """One busy/free interval. Attributes: - start: Start of the interval (``UTCDateTime`` string). - end: End of the interval (``UTCDateTime`` string). + start: Start of the interval (``UTCDateTime`` string, always + ``Z``-suffixed). Normalized to this one shape regardless of + whether it came from ``Principal/getAvailability`` or the + ``CalendarEvent/query`` fallback, since :rfc:`8984` allows a + JSCalendar ``start`` to be UTC, time-zone-qualified, or + floating; a floating start has no true UTC equivalent and is + treated as UTC. + end: End of the interval (``UTCDateTime`` string), same + normalization as ``start``. busy_status: One of ``"confirmed"``, ``"tentative"``, ``"unavailable"`` (see ``constants.BUSY_STATUS_*``). event: The underlying event, when the server populated it. This can diff --git a/src/calendaring_jmap/objects/calendar.py b/src/calendaring_jmap/objects/calendar.py index 7b42b99..e9c94f5 100644 --- a/src/calendaring_jmap/objects/calendar.py +++ b/src/calendaring_jmap/objects/calendar.py @@ -112,7 +112,7 @@ def _bound_client(self) -> JMAPClient | AsyncJMAPClient: @property def _bound_async_client(self: JMAPCalendar[Literal[True]]) -> AsyncJMAPClient: - """Same as :attr:`_bound_client`, narrowed for the ``_async_*`` helpers. + """Same as ``_bound_client``, narrowed for the ``_async_*`` helpers. Only called from a method whose public overload already restricted ``self`` to ``JMAPCalendar[Literal[True]]``, i.e. one that @@ -206,10 +206,9 @@ def search(self: Any, **searchargs: Any): (maps to JMAP ``before`` filter). - ``text`` (str): free-text search across title, description, locations, and participants. - - ``has_attachment`` (bool): see :meth:`JMAPClient.search_events`. - - ``participant_email`` (str): see :meth:`JMAPClient.search_events`. - - ``participation_status`` (str): see :meth:`JMAPClient.search_events`. - - ``participant_role`` (str): see :meth:`JMAPClient.search_events`. + - ``has_attachment`` (bool), ``participant_email`` (str), + ``participation_status`` (str), ``participant_role`` (str): see + :meth:`~calendaring_jmap.client.JMAPClient.search_events`. Returns: List of :class:`~calendaring_jmap.objects.calendar_object.JMAPCalendarObject` diff --git a/src/calendaring_jmap/objects/calendar_object.py b/src/calendaring_jmap/objects/calendar_object.py index 4b413fc..1081566 100644 --- a/src/calendaring_jmap/objects/calendar_object.py +++ b/src/calendaring_jmap/objects/calendar_object.py @@ -5,8 +5,8 @@ JMAP calendar resource object. Wraps a raw JSCalendar CalendarEvent dict with a minimal interface: -``.id``, ``.parent``, :meth:`get_data`, :meth:`get_icalendar_instance`, -:meth:`edit_icalendar_instance`, and :meth:`save`. +``.id``, ``.parent``, ``get_data``, ``get_icalendar_instance``, +``edit_icalendar_instance``, and ``save``. """ from __future__ import annotations @@ -56,7 +56,7 @@ def get_data(self) -> dict: return self.data def get_icalendar_instance(self) -> icalendar.Calendar: - """Return an :class:`icalendar.Calendar` for this object. + """Return an :class:`icalendar.Calendar ` for this object. The result is cached after the first conversion. Treat it as read-only; use :meth:`edit_icalendar_instance` to make and persist @@ -68,9 +68,11 @@ def get_icalendar_instance(self) -> icalendar.Calendar: @contextmanager def edit_icalendar_instance(self): - """Borrow an editable :class:`icalendar.Calendar` for this object. + """Borrow an editable + :class:`icalendar.Calendar ` for this object. - Yields the cached :class:`icalendar.Calendar` for in-place editing. + Yields the cached + :class:`icalendar.Calendar ` for in-place editing. Call :meth:`save` after the ``with`` block to persist changes to the server. Note: :meth:`save` is sync-only. Async-backed calendars cannot use diff --git a/src/calendaring_jmap/objects/contact.py b/src/calendaring_jmap/objects/contact.py index fbbadf3..ef99071 100644 --- a/src/calendaring_jmap/objects/contact.py +++ b/src/calendaring_jmap/objects/contact.py @@ -17,7 +17,7 @@ from dataclasses import dataclass, field -#: Sort key for an EmailAddress with no explicit ``pref`` (:rfc:`9553#section-1.5.3` +#: Sort key for an EmailAddress with no explicit ``pref`` (RFC 9553 section 1.5.3 #: caps the real range at 1-100; one past that sorts after every explicit #: value, matching "no preference set" being least preferred of all). _UNSET_PREF_SORT_KEY = 101 @@ -39,10 +39,11 @@ class JMAPAddressBook: share_with: Map of Principal id (:rfc:`9670#section-2`) to a dict of AddressBookRights (``mayRead``, ``mayWrite``, ``mayShare``, ``mayDelete``). ``None`` if unshared or the server lacks - :rfc:`9670` support (:rfc:`9610#section-2`), confirmed live on - Cyrus. Confirmed live that Stalwart returns ``{}`` here instead - when unshared, not ``None``: a caller checking "is this - shared" should treat the value as falsy + :rfc:`9670` support (:rfc:`9610#section-2`), verified against + Cyrus. Stalwart behaves differently: it returns ``{}`` here + instead when unshared, not ``None``, found by testing directly + against it. A caller checking "is this shared" should treat + the value as falsy (``not address_book.share_with``), not test for ``is None`` specifically. my_rights: AddressBookRights dict for the current user, server-set. @@ -84,7 +85,7 @@ class JMAPContact: Attributes: id: Server-assigned identifier. May differ from ``uid``. uid: The card's own JSContact uid (:rfc:`9553#section-2.1.9`). - RFC 9553 treats this as mandatory on every Card, but confirmed + That section marks it mandatory on every Card, but confirmed live that Stalwart's ``ContactCard/get`` never returns it at all, even when explicitly requested via ``properties``. Unlike ``id``, this is not required here: a strict-missing check would diff --git a/src/calendaring_jmap/tests/test_jmap_integration.py b/src/calendaring_jmap/tests/test_jmap_integration.py index 492b43b..9591d9e 100644 --- a/src/calendaring_jmap/tests/test_jmap_integration.py +++ b/src/calendaring_jmap/tests/test_jmap_integration.py @@ -5,11 +5,10 @@ Integration tests for calendaring-jmap against live JMAP servers. Cyrus (port 8802): - docker-compose -f tests/docker/cyrus/docker-compose.yml up -d + tests/docker/cyrus/start.sh Stalwart (port 8809): - docker-compose -f tests/docker/stalwart/docker-compose.yml up -d - ./tests/docker/stalwart/setup_stalwart.sh + tests/docker/stalwart/start.sh Each server's test classes are skipped automatically when that server is not reachable, so a missing server is quiet, not a failure. @@ -93,10 +92,10 @@ def _vevent_block( ) -> str: """Build one ``BEGIN:VEVENT``...``END:VEVENT`` block. - Shared by :func:`_vevent_ical` (one VEVENT, the common case) and - :func:`_recurring_override_ical` (a master VEVENT plus one + Shared by ``_vevent_ical`` (one VEVENT, the common case) and + ``_recurring_override_ical`` (a master VEVENT plus one RECURRENCE-ID override VEVENT, which needs two blocks sharing one UID - rather than :func:`_vevent_ical`'s own fresh ``uuid.uuid4()`` per call). + rather than ``_vevent_ical``'s own fresh ``uuid.uuid4()`` per call). """ end = start + duration recurrence_id_line = ( @@ -124,8 +123,8 @@ def _vevent_ical( ) -> str: """Build a minimal single-VEVENT VCALENDAR string. - Shared by :func:`_minimal_ical`, :func:`_recurring_ical`, and - :func:`_invite_ical`, which each add their own ``extra_lines`` + Shared by ``_minimal_ical``, ``_recurring_ical``, and + ``_invite_ical``, which each add their own ``extra_lines`` (``RRULE``, ``ORGANIZER``/``ATTENDEE``) before ``END:VEVENT``. """ uid = str(uuid.uuid4()) @@ -192,7 +191,7 @@ def _recurring_override_ical( None of ``_minimal_ical``/``_recurring_ical``/``_invite_ical`` support a second, override VEVENT; they each build exactly one via - :func:`_vevent_ical`. Shared by every recurrenceOverrides integration + ``_vevent_ical``. Shared by every recurrenceOverrides integration test that needs a real override instance round-tripped through a live server. """ @@ -382,7 +381,7 @@ def second_account_client(request, server): def _own_account_id(server: str, primary: bool) -> str: """Resolve the account id a user's own session sees for itself, by opening a session as that user and reading it back. Shared by - :func:`owner_account_id`/:func:`second_account_id`: both need this same + ``owner_account_id``/``second_account_id``: both need this same self-resolution (this client has no Principal/query support yet, so there's no other way to look up someone else's account id).""" if server == "cyrus": @@ -785,7 +784,7 @@ def test_sequence_increments_on_substantive_update(self, calendar_management_cli non-per-user property changes and the account is the event's origin. The client never sets sequence itself; this proves the server does, rather than assuming it from the spec text alone. - Confirmed live: Cyrus does this; Stalwart does not touch sequence + Tested directly: Cyrus does this; Stalwart does not touch sequence on update at all, a real spec deviation, not a test bug.""" if server == "stalwart": pytest.skip("Stalwart does not increment sequence on CalendarEvent/set update") @@ -940,8 +939,8 @@ def test_attach_property_roundtrip(self, event_client, event_calendar_id, server def test_url_property_roundtrip(self, event_client, event_calendar_id): """ical_to_jscal/jscal_to_ical convert URL <-> links (no rel). - Confirmed live that both Cyrus and Stalwart accept and round-trip - a links entry with no rel at all; no server quirk here, unlike + Both Cyrus and Stalwart accept and round-trip a links entry with + no rel at all, verified against each; no server quirk here, unlike ATTACH's rel-persistence bug. """ ical = _vevent_ical( @@ -959,8 +958,8 @@ def test_url_property_roundtrip(self, event_client, event_calendar_id): def test_virtual_location_description_roundtrip(self, event_client, event_calendar_id, server): """VirtualLocation.description (via a VCONFERENCE correlated to its - sibling CONFERENCE by URI) round-trips on Stalwart. Confirmed live - that Cyrus rejects description on a VirtualLocation outright with + sibling CONFERENCE by URI) round-trips on Stalwart. Cyrus, tested + directly, rejects description on a VirtualLocation outright with invalidProperties, on both create and update, independent of descriptionContentType or VCONFERENCE entirely; this is a real Cyrus limitation on a valid RFC 8984 property, not a bug in this @@ -1105,7 +1104,7 @@ def test_recurrence_override_participant_and_alert_server_quirks( def test_recurrence_override_location_patch_resolves_when_master_has_its_own_location( self, event_client, event_calendar_id, server ): - """Confirmed live on both servers: Cyrus returns a map-typed + """Tested against both servers: Cyrus returns a map-typed recurrenceOverrides property (locations, participants, virtualLocations, alerts, keywords) as flattened per-key JSON-Pointer entries specifically when the master event already has @@ -1151,10 +1150,10 @@ def test_recurrence_override_location_patch_resolves_when_master_has_its_own_loc @_sync_servers class TestJMAPFreeBusyIntegration: - """Confirmed live (both servers, this repo's own verification, not just - the spec text) that Principal/getAvailability genuinely works on both - Cyrus and Stalwart, so these tests exercise the real primary path, not - the fallback. Neither test server lacks the base + """This repo's own verification, not just the spec text, confirms + Principal/getAvailability genuinely works on both Cyrus and Stalwart, + so these tests exercise the real primary path, not the fallback. + Neither test server lacks the base urn:ietf:params:jmap:principals capability. The fallback path's own mechanism (expandRecurrences) is proven correct in test_fallback_recurring_event_busy_intervals below by calling the @@ -1188,7 +1187,7 @@ def test_get_availability_returns_busy_interval_for_event( event_client.delete_event(event_id) def test_get_availability_show_details(self, event_client, event_calendar_id, server): - """Confirmed live: Cyrus returns full event details by default with + """Tested directly: Cyrus returns full event details by default with show_details=True. Stalwart does not, since it additionally requires eventProperties to be set to one of its own narrow supported set, which get_availability doesn't currently pass; on @@ -1222,7 +1221,7 @@ def test_fallback_recurring_event_busy_intervals(self, event_client, event_calen to give this mechanism live coverage. Without expandRecurrences, a recurring series returns one interval carrying only the master occurrence's own start, not the occurrences that actually fall in - the window. Confirmed live during this feature's own planning.""" + the window, a limitation found while planning this feature.""" ical = _recurring_ical( "FREQ=WEEKLY", title="Weekly Fallback Availability Test", @@ -1333,7 +1332,7 @@ async def test_get_availability_returns_busy_interval_for_event( @_sync_servers class TestAttachmentIntegration: - """Confirmed live on both servers: uploadUrl/downloadUrl are always + """Tested against both servers: uploadUrl/downloadUrl are always present, upload/download round-trip byte-for-byte, and the calendars draft's blobId-on-Link extension isn't persisted by either server (attach_to_event uses href instead, see its docstring). The Cyrus @@ -1349,8 +1348,8 @@ def test_upload_download_roundtrip(self, event_client): assert downloaded == data def test_download_tolerates_omitted_type_and_name(self, event_client): - """Confirmed live: both servers resolve a blob by blobId alone; an - omitted type/name only affects the response's own Content-Type/ + """Verified against both servers: each resolves a blob by blobId + alone; an omitted type/name only affects the response's own Content-Type/ Content-Disposition headers, not whether the download succeeds.""" data = b"omitted type and name" blob_id = event_client.upload_attachment(data, "text/plain") @@ -1479,8 +1478,8 @@ def address_book_id(event_client, server): @_sync_servers class TestContactsIntegration: - """Confirmed live that both Cyrus and Stalwart advertise - urn:ietf:params:jmap:contacts under the same account already used for + """Both Cyrus and Stalwart advertise urn:ietf:params:jmap:contacts, + verified against each, under the same account already used for calendars, so no graceful-fallback path is exercised here; that path is covered only by the mocked unit tests, the same way get_availability's own fallback-path unit test covers a scenario neither live server @@ -1557,7 +1556,7 @@ def test_search_contacts_by_email(self, event_client, address_book_id, server): self._destroy_contact(event_client, contact_id) def test_search_contacts_by_text(self, event_client, address_book_id, server): - """Confirmed live: Cyrus matches the text filter as a substring + """Tested directly: Cyrus matches the text filter as a substring against a card's name, not exact-only. Stalwart v0.16.21 does not match it against the name at all, only against the email address (see search_contacts's own docstring); a text search for any part @@ -1628,14 +1627,14 @@ def test_contact_uid(self, event_client, address_book_id, server): @_stalwart_skip class TestPushIntegration: - """Confirmed live that Cyrus does not implement PushSubscription/get or + """Tested directly: Cyrus doesn't implement PushSubscription/get or /set at all (unknownMethod on both), so there is no Cyrus counterpart to this class: nothing here is exercisable against that server. The lifecycle tests below (create, renew, destroy) use a throwaway ``https://`` URL that never actually receives the server's PushVerification POST, since this test suite has no HTTP server of its - own to receive it. Confirmed live that Stalwart accepts the create + own to receive it. Stalwart, verified directly, accepts the create regardless and lets renew/destroy proceed on an unverified subscription, so the create/renew/destroy lifecycle is fully exercisable without completing verification. @@ -1680,8 +1679,8 @@ def test_renew_push_updates_expiry(self, stalwart_client): stalwart_client.unsubscribe_push(subscription_id) def test_confirm_push_verification_rejects_wrong_code(self, stalwart_client): - # Confirmed live that Stalwart rejects a wrong verificationCode - # with invalidProperties rather than silently ignoring it, even + # Stalwart rejects a wrong verificationCode with invalidProperties + # rather than silently ignoring it, verified against a running instance, even # against a real, currently-unverified subscription. subscription_id = stalwart_client.subscribe_push( "https://example.invalid/push", "integration-test-device" @@ -1731,8 +1730,8 @@ class TestRicherEventSearchIntegration: ical_to_jscal conversion, not a hand-built raw JMAP dict) and asserts on search_events's returned results. - Confirmed live that both Cyrus and Stalwart drop the "attendee" role - from a participant's roles map, so participant_role="attendee" itself + Both Cyrus and Stalwart drop the "attendee" role from a participant's + roles map, verified against each, so participant_role="attendee" itself is not directly testable here; _invite_ical(role="CHAIR") is used instead, which survives on both servers, and test_participant_role_attendee_confirmed_dropped locks in the known @@ -1757,8 +1756,8 @@ def test_has_attachment_true_finds_event_with_attachment( results = event_client.search_events(calendar_id=event_calendar_id, has_attachment=True) matched = any(r.id == event_id for r in results) if server == "cyrus": - # Confirmed live: Cyrus drops rel: "enclosure" on read-back, - # so has_attachment=True cannot find this event there. + # Cyrus drops rel: "enclosure" on read-back, seen against a + # running instance, so has_attachment=True cannot find this event there. assert not matched, ( f"{server}: expected has_attachment=True to miss this event " "(known rel: enclosure drop); it matched instead, meaning " diff --git a/src/calendaring_jmap/tests/test_jmap_unit.py b/src/calendaring_jmap/tests/test_jmap_unit.py index 0ac1dd4..7e9c4dc 100644 --- a/src/calendaring_jmap/tests/test_jmap_unit.py +++ b/src/calendaring_jmap/tests/test_jmap_unit.py @@ -502,9 +502,9 @@ def test_empty_string_upload_and_download_url_become_none(self): assert session.download_url is None def test_resolves_relative_upload_and_download_url(self): - """Confirmed live against Cyrus: uploadUrl/downloadUrl can be a - relative path, same as apiUrl (see test_parses_api_url's sibling - rewrite test), and must be resolved the same way.""" + """Tested against a running Cyrus instance: uploadUrl/downloadUrl + can be a relative path, same as apiUrl (see test_parses_api_url's + sibling rewrite test), and must be resolved the same way.""" data = dict(_SESSION_JSON) data["uploadUrl"] = "/jmap/upload/{accountId}/" data["downloadUrl"] = "/jmap/download/{accountId}/{blobId}/{name}?accept={type}" @@ -1159,8 +1159,8 @@ def test_from_jmap_raises_when_name_missing(self): JMAPAddressBook.from_jmap({"id": "ab3"}) def test_from_jmap_share_with_empty_dict_stays_empty_dict(self): - # Confirmed live that Stalwart returns {} for an unshared address - # book, not null; this must not collapse to None. + # Stalwart returns {} for an unshared address book, not null, + # verified against a running instance; this must not collapse to None. ab = JMAPAddressBook.from_jmap({"id": "ab4", "name": "Unshared", "shareWith": {}}) assert ab.share_with == {} @@ -1181,8 +1181,8 @@ def test_from_jmap_full(self): def test_from_jmap_minimal_uses_defaults(self): contact = JMAPContact.from_jmap(_CONTACT_JSON_MINIMAL) assert contact.id == "c2" - # Confirmed live that Stalwart's ContactCard/get never returns uid - # at all, even when explicitly requested via properties, contrary + # Stalwart's ContactCard/get never returns uid at all, found by + # testing directly against it, even when explicitly requested via properties, contrary # to RFC 9553 treating it as mandatory; unlike id, a missing uid # must not raise, or every Stalwart contact would break. assert contact.uid is None @@ -1592,7 +1592,7 @@ def test_parse_push_subscription_set_returns_not_updated(self): def _make_client() -> JMAPClient: """Return a JMAPClient with a mocked Session cache, no HTTP session set up yet. Shared by every sync test helper that needs a bare mocked client - (:func:`_make_client_with_mocked_session`, ``_MockedClientMixin``).""" + (``_make_client_with_mocked_session``, ``_MockedClientMixin``).""" client = JMAPClient(url=_JMAP_URL, username=_USERNAME, password=_PASSWORD) client._session_cache = Session(api_url=_API_URL, account_id=_USERNAME, state="state-abc") return client @@ -2089,6 +2089,11 @@ def test_returns_client_with_explicit_params(self): assert isinstance(client, JMAPClient) assert client.url == _JMAP_URL + def test_passes_through_a_prebuilt_auth_object(self): + auth = HTTPBasicAuth(_USERNAME, _PASSWORD) + client = get_jmap_client(url=_JMAP_URL, auth=auth) + assert client._auth is auth + def test_returns_none_when_no_config(self, monkeypatch, tmp_path): for var in ("JMAP_URL", "JMAP_USERNAME", "JMAP_PASSWORD", "JMAP_CONFIG_FILE"): monkeypatch.delenv(var, raising=False) @@ -2126,6 +2131,11 @@ def test_returns_client_with_explicit_params(self): assert isinstance(client, AsyncJMAPClient) assert client.url == _JMAP_URL + def test_passes_through_a_prebuilt_auth_object(self): + auth = HTTPBasicAuth(_USERNAME, _PASSWORD) + client = get_async_jmap_client(url=_JMAP_URL, auth=auth) + assert client._auth is auth + def test_returns_none_when_no_config(self, monkeypatch, tmp_path): for var in ("JMAP_URL", "JMAP_USERNAME", "JMAP_PASSWORD", "JMAP_CONFIG_FILE"): monkeypatch.delenv(var, raising=False) @@ -2468,8 +2478,8 @@ def test_build_get_availability_structure(self): assert isinstance(call_id, str) def test_build_get_availability_has_no_account_id(self): - # Confirmed live against Cyrus: accountId in this method's own args - # dict fails with invalidArguments, even with a correct value. + # Tested against a running Cyrus instance: accountId in this method's + # own args dict fails with invalidArguments, even with a correct value. _, args, _ = build_get_availability( "principal1", "2024-01-01T00:00:00Z", "2024-01-08T00:00:00Z" ) @@ -4984,8 +4994,9 @@ def test_recurrence_override_patch_virtual_location_description_becomes_child_vc assert "DESCRIPTION:override desc" in events[2] def test_recurrence_override_patch_flattened_keywords_pointers_become_child_categories(self): - # Confirmed live: Cyrus returns a recurrenceOverrides patch as - # flattened per-key JSON-Pointer entries ("keywords/urgent": true) + # Observed against a running Cyrus instance: it returns a + # recurrenceOverrides patch as flattened per-key JSON-Pointer + # entries ("keywords/urgent": true) # rather than the single whole-map "keywords" key this converter # itself always sends (RFC 8984 section 1.4.9 permits both). "null" # removes a key already on the master; a non-null value sets one. @@ -5357,7 +5368,7 @@ def _get_response( with ``list``/``notFound``, plus ``state`` when given (for a sync-token call that only cares about the response's own state, e.g. ``get_sync_token``/``get_task_sync_token``). Shared the same way as - :func:`_set_response`.""" + ``_set_response``.""" args: dict = {"accountId": _USERNAME, "list": items, "notFound": []} if state is not None: args["state"] = state @@ -5376,7 +5387,7 @@ def _changes_response( ) -> dict: """A minimal ``/changes`` response envelope: one methodResponse with ``oldState``/``newState``/``hasMoreChanges``/``created``/``updated``/ - ``destroyed``. Shared the same way as :func:`_set_response`.""" + ``destroyed``. Shared the same way as ``_set_response``.""" return { "methodResponses": [ [ @@ -5435,7 +5446,7 @@ def _mock_http(self, client, response=None, side_effect=None): return mock_http def _capturing_client_for(self, client, resp) -> dict: - """Like :meth:`_capturing_client`, but for an already-built ``client``.""" + """Like ``_capturing_client``, but for an already-built ``client``.""" captured: dict = {} def capturing_post(*args, **kwargs): @@ -6391,7 +6402,7 @@ class TestJMAPClientFreeBusy(_MockedClientMixin): def _fallback_query_get_response(self, events: list[dict]) -> dict: """Batched [CalendarEvent/query, CalendarEvent/get] response for the availability fallback path specifically. Deliberately not - :func:`_query_get_response`: fallback events carry only + ``_query_get_response``: fallback events carry only ``start``/``duration``/``freeBusyStatus`` (what ``_busy_intervals_from_events`` reads), no ``id``, so a real ``ids`` list can't be derived from them the way the shared helper @@ -6830,8 +6841,8 @@ def test_subscribe_push_raises_on_not_created(self, monkeypatch): assert exc_info.value.error_type == "invalidProperties" def test_subscribe_push_raises_unknown_method_when_unsupported(self, monkeypatch): - # Confirmed live that Cyrus does not implement PushSubscription/set - # at all: this is what a real, unsupported server looks like, and + # Tested directly: Cyrus doesn't implement PushSubscription/set at + # all. This is what a real, unsupported server looks like, and # there is deliberately no capability-check fallback for it (unlike # get_address_books), since PushSubscription needs no capability # beyond core, which every request already sends. diff --git a/tests/docker/cyrus/README.md b/tests/docker/cyrus/README.md index 8d09f56..4e93796 100644 --- a/tests/docker/cyrus/README.md +++ b/tests/docker/cyrus/README.md @@ -24,7 +24,7 @@ Run the integration tests from the repo root: pytest src/calendaring_jmap/tests/test_jmap_integration.py ``` -To stop: `./stop.sh` (also removes the container's volumes, so the next start is a fresh instance). +To stop: `./stop.sh`. The container stores no persistent data outside its own filesystem, so the next `./start.sh` is always a fresh instance regardless. ## Configuration @@ -41,5 +41,5 @@ Pinned by digest in `docker-compose.yml` rather than `:latest`: this image only ```bash docker-compose logs -f cyrus # view logs docker-compose restart cyrus # restart -docker-compose down -v # stop and wipe all data +docker-compose down -v # stop (-v is a no-op here; the container has no named volumes) ``` diff --git a/tests/docker/stalwart/README.md b/tests/docker/stalwart/README.md index 8a65366..bb41702 100644 --- a/tests/docker/stalwart/README.md +++ b/tests/docker/stalwart/README.md @@ -3,7 +3,7 @@ # Testing with Stalwart -Docker setup for running integration tests against a real Stalwart JMAP server. Cyrus doesn't implement JMAP Tasks, so this is the one used for task-related integration tests. +Docker setup for running integration tests against a real Stalwart JMAP server. Cyrus doesn't implement `PushSubscription/get`/`/set` at all, so push notification integration tests run only against Stalwart. Most other integration tests run against both servers. Neither server implements the JMAP Tasks draft, so there are no task integration tests against either. ## Requirements @@ -24,7 +24,7 @@ Run the integration tests from the repo root: pytest src/calendaring_jmap/tests/test_jmap_integration.py ``` -To stop: `./stop.sh` (also removes the container's volumes, so the next start is a fresh instance). +To stop: `./stop.sh`. Stalwart's data directory is a `tmpfs` mount, not a named volume, so the next `./start.sh` is always a fresh instance regardless. ## Configuration @@ -43,5 +43,5 @@ Pinned to a specific released version (`v0.16.21` as of writing) rather than `:l ```bash docker-compose logs -f stalwart # view logs docker-compose restart stalwart # restart -docker-compose down -v # stop and wipe all data +docker-compose down -v # stop (-v is a no-op here; the data directory is a tmpfs mount, not a named volume) ``` From b0c71d5b04dc0fa93d06ecb1f053987f3f447545 Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Thu, 8 Oct 2026 11:41:48 +0530 Subject: [PATCH 2/3] fix: install docutils in CI so Vale can parse RST --- .github/workflows/tests.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b72fd9e..43b48c1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -74,6 +74,14 @@ jobs: with: persist-credentials: false + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.13" + + - name: Install docutils + run: pip install docutils==0.22.4 + - name: Install Vale run: | curl -sL https://github.com/errata-ai/vale/releases/download/v3.24.0/vale_3.24.0_Linux_64-bit.tar.gz | tar -xz -C /usr/local/bin vale From 1bec0bb12e932ededc7b5f5e99ef62f1fa85bfc1 Mon Sep 17 00:00:00 2001 From: Sashank Bhamidi Date: Thu, 8 Oct 2026 12:34:29 +0530 Subject: [PATCH 3/3] fix: theme Read the Docs' diff-preview colors for dark mode --- docs/_static/css/pycal-theme.css | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/_static/css/pycal-theme.css b/docs/_static/css/pycal-theme.css index d3dda7a..a120466 100644 --- a/docs/_static/css/pycal-theme.css +++ b/docs/_static/css/pycal-theme.css @@ -281,3 +281,19 @@ a.reference.external::after { color: var(--pst-color-accent); text-decoration-color: var(--pst-color-accent); } + +/* Read the Docs' "Show diff" PR preview overlay (.doc-diff-added/-removed + on plain / tags). RTD ships no theme-aware default for these, + just light-mode-tuned colors, which read as a muddy, low-contrast block + against a dark background. Reusing the same success/danger tokens the + admonitions already use keeps it legible and on-theme in both modes. */ +ins.doc-diff-added { + background-color: var(--pst-color-success-bg); + color: var(--pst-color-text-base); + text-decoration: none; +} + +del.doc-diff-removed { + background-color: var(--pst-color-danger-bg); + color: var(--pst-color-text-base); +}