pax_global_header00006660000000000000000000000064151055072610014514gustar00rootroot0000000000000052 comment=fe8800b06e27fbe2f6576ce39ac4bc1e706a6573 allenporter-ical-fe8800b/000077500000000000000000000000001510550726100153675ustar00rootroot00000000000000allenporter-ical-fe8800b/.cruft.json000066400000000000000000000010151510550726100174600ustar00rootroot00000000000000{ "template": "http://github.com/allenporter/cookiecutter-python", "commit": "71e4c726100fa18c949f32343442c0acb36362fe", "checkout": null, "context": { "cookiecutter": { "full_name": "Allen Porter", "email": "allen.porter@gmail.com", "github_username": "allenporter", "project_name": "ical", "description": "Python iCalendar implementation (rfc 2445)", "version": "8.0.1", "_template": "http://github.com/allenporter/cookiecutter-python" } }, "directory": null } allenporter-ical-fe8800b/.github/000077500000000000000000000000001510550726100167275ustar00rootroot00000000000000allenporter-ical-fe8800b/.github/ISSUE_TEMPLATE/000077500000000000000000000000001510550726100211125ustar00rootroot00000000000000allenporter-ical-fe8800b/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000007171510550726100236110ustar00rootroot00000000000000--- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is, and the version of the ics library used. **To Reproduce** Sample code to reproduce the behavior, or ics file and error message. **Expected behavior** A clear and concise description of what you expected to happen. **Additional context** Add any other context about the problem here. allenporter-ical-fe8800b/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000011231510550726100246340ustar00rootroot00000000000000--- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. allenporter-ical-fe8800b/.github/renovate.json5000066400000000000000000000007571510550726100215430ustar00rootroot00000000000000{ $schema: 'https://docs.renovatebot.com/renovate-schema.json', extends: [ 'config:recommended', ], assignees: [ 'allenporter', ], packageRules: [ { description: 'Minor updates are automatic', automerge: true, automergeType: 'branch', matchUpdateTypes: [ 'minor', 'patch', ], }, ], pip_requirements: { managerFilePatterns: [ '/requirements_dev.txt/', ], }, 'pre-commit': { enabled: true, }, } allenporter-ical-fe8800b/.github/workflows/000077500000000000000000000000001510550726100207645ustar00rootroot00000000000000allenporter-ical-fe8800b/.github/workflows/benchmark.yaml000066400000000000000000000012461510550726100236050ustar00rootroot00000000000000--- name: Python benchmarks on: push: branches: - main pull_request: branches: - main jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false steps: - uses: actions/checkout@v5 - name: Install uv uses: astral-sh/setup-uv@v7 with: python-version: ${{ matrix.python-version }} enable-cache: true cache-dependency-glob: "requirements_dev.txt" activate-environment: true - name: Install dependencies run: | uv pip install -r requirements_dev.txt - name: Run benchmarks with pytest run: | pytest --benchmark-only allenporter-ical-fe8800b/.github/workflows/cruft.yaml000066400000000000000000000042261510550726100227770ustar00rootroot00000000000000--- name: Update repository with Cruft permissions: contents: write pull-requests: write actions: write workflows: write on: schedule: - cron: "0 0 * * *" jobs: update: runs-on: ubuntu-latest strategy: fail-fast: true matrix: include: - add-paths: . body: Use this to merge the changes to this repository. branch: cruft/update commit-message: "chore: accept new Cruft update" title: New updates detected with Cruft - add-paths: .cruft.json body: Use this to reject the changes in this repository. branch: cruft/reject commit-message: "chore: reject new Cruft update" title: Reject new updates detected with Cruft steps: - uses: actions/checkout@v5 - name: Set up Python uses: actions/setup-python@v6 - name: Install Cruft run: pip3 install cruft - name: Check if update is available continue-on-error: false id: check run: | CHANGES=0 if [ -f .cruft.json ]; then if ! cruft check; then CHANGES=1 fi else echo "No .cruft.json file" fi echo "has_changes=$CHANGES" >> "$GITHUB_OUTPUT" - name: Run update if available if: steps.check.outputs.has_changes == '1' run: | git config --global user.email "allen.porter@gmail.com" git config --global user.name "Allen Porter" cruft update --skip-apply-ask --refresh-private-variables git restore --staged . - name: Create pull request if: steps.check.outputs.has_changes == '1' uses: peter-evans/create-pull-request@v7 with: token: ${{ secrets.GITHUB_TOKEN }} add-paths: ${{ matrix.add-paths }} commit-message: ${{ matrix.commit-message }} branch: ${{ matrix.branch }} title: ${{ matrix.title }} body: | This is an autogenerated PR. ${{ matrix.body }} [Cruft](https://cruft.github.io/cruft/) has detected updates from the Cookiecutter repository. allenporter-ical-fe8800b/.github/workflows/lint.yaml000066400000000000000000000022521510550726100226170ustar00rootroot00000000000000--- name: Lint on: push: branches: - main - renovate/** pull_request: branches: - main jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false steps: - uses: actions/checkout@v5 - uses: codespell-project/actions-codespell@v2.2 with: check_hidden: false skip: ./ical/tzif/extended_timezones.py,./tests/tzif/testdata/rfc8536-v3.yaml - name: Run yamllint uses: ibiqlik/action-yamllint@v3 with: file_or_dir: "./" config_file: "./.yaml-lint.yaml" strict: true - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "requirements_dev.txt" activate-environment: true - name: Install dependencies run: | uv pip install -r requirements_dev.txt - name: Run Ruff Check run: | ruff check --output-format=github . - name: Run Ruff Format run: | ruff format . - name: Static typing with mypy run: | mypy --install-types --non-interactive --no-warn-unused-ignores . allenporter-ical-fe8800b/.github/workflows/pages.yaml000066400000000000000000000021241510550726100227460ustar00rootroot00000000000000--- name: Deploy static content to Pages on: push: branches: - main workflow_dispatch: permissions: contents: read pages: write id-token: write actions: read concurrency: group: "pages" cancel-in-progress: true jobs: deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest strategy: fail-fast: false steps: - uses: actions/checkout@v5 - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "requirements_dev.txt" activate-environment: true - name: Install dependencies run: | uv pip install -r requirements_dev.txt - run: pdoc ./ical -o docs/ - name: Setup Pages uses: actions/configure-pages@v5 - name: Upload artifact uses: actions/upload-pages-artifact@v4 with: # Upload entire repository path: 'docs/' - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 allenporter-ical-fe8800b/.github/workflows/publish.yaml000066400000000000000000000023501510550726100233160ustar00rootroot00000000000000--- name: Upload Python Package on: release: types: [created] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - name: Set up Python uses: actions/setup-python@v6 - name: Install dependencies run: | python -m pip install --upgrade pip pip install build --user - name: Build a binary wheel and a source tarball run: python3 -m build - name: Store the distribution packages uses: actions/upload-artifact@v5 with: name: python-package-distributions path: dist/ publish-to-pypi: name: >- Publish Python 🐍 distribution 📦 to PyPI if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes needs: - build runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/ical permissions: id-token: write # IMPORTANT: mandatory for trusted publishing steps: - name: Download all the dists uses: actions/download-artifact@v6 with: name: python-package-distributions path: dist/ - name: Publish distribution 📦 to PyPI uses: pypa/gh-action-pypi-publish@release/v1 allenporter-ical-fe8800b/.github/workflows/test.yaml000066400000000000000000000017021510550726100226270ustar00rootroot00000000000000--- name: Test on: push: branches: - main - renovate/** pull_request: branches: - main jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: - "3.13" - "3.14" steps: - uses: actions/checkout@v5 - name: Install uv uses: astral-sh/setup-uv@v7 with: python-version: ${{ matrix.python-version }} enable-cache: true cache-dependency-glob: "requirements_dev.txt" activate-environment: true - name: Install dependencies run: | uv pip install -r requirements_dev.txt - name: Test with pytest run: | pytest --cov=ical --cov-report=term-missing - uses: codecov/codecov-action@v5.5.1 with: token: ${{ secrets.CODECOV_TOKEN }} env_vars: OS,PYTHON fail_ci_if_error: true verbose: true allenporter-ical-fe8800b/.gitignore000066400000000000000000000034221510550726100173600ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ .DS_Store allenporter-ical-fe8800b/.pre-commit-config.yaml000066400000000000000000000023431510550726100216520ustar00rootroot00000000000000--- repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: trailing-whitespace exclude: '^tests/.*(testdata|__snapshots__)/.*(yaml|ics|ambr)$' - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.14.4 hooks: - id: ruff-check types_or: - python - pyi args: - --fix - --exit-non-zero-on-fix - id: ruff-format types_or: - python - pyi - repo: local hooks: - id: mypy name: mypy entry: script/run-mypy.sh language: script types: [python] require_serial: true - repo: https://github.com/codespell-project/codespell rev: v2.4.1 hooks: - id: codespell exclude: | (?x)^( tests/tzif/testdata/rfc8536-v3.yaml| ical/tzif/extended_timezones.py )$ - repo: https://github.com/adrienverge/yamllint.git rev: v1.37.1 hooks: - id: yamllint exclude: '^tests/tool/testdata/.*\.yaml$' args: - --strict - -c - ".yaml-lint.yaml" allenporter-ical-fe8800b/.ruff.toml000066400000000000000000000010321510550726100173000ustar00rootroot00000000000000target-version = "py313" [lint] ignore = ["E501"] select = [ "ASYNC210", # Async functions should not call blocking HTTP methods "ASYNC220", # Async functions should not create subprocesses with blocking methods "ASYNC221", # Async functions should not run processes with blocking methods "ASYNC222", # Async functions should not wait on processes with blocking methods "ASYNC230", # Async functions should not open files with blocking methods like open "ASYNC251", # Async functions should not call time.sleep ] allenporter-ical-fe8800b/.yaml-lint.yaml000066400000000000000000000005651510550726100202450ustar00rootroot00000000000000--- ignore: | venv tests/testdata extends: default rules: truthy: allowed-values: ['true', 'false', 'on', 'yes'] comments: min-spaces-from-content: 1 line-length: disable braces: min-spaces-inside: 0 max-spaces-inside: 1 brackets: min-spaces-inside: 0 max-spaces-inside: 0 indentation: spaces: 2 indent-sequences: consistent allenporter-ical-fe8800b/CODE_OF_CONDUCT.md000066400000000000000000000121471510550726100201730ustar00rootroot00000000000000# Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at allen.porter@gmail.com. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. allenporter-ical-fe8800b/CONTRIBUTING.md000066400000000000000000000011171510550726100176200ustar00rootroot00000000000000# Contributing Thank you for contributing! This section describes the typical steps in setting up a development environment. ## Setup a virtual environment ``` $ uv venv --python=3.13 $ source .venv/bin/activate $ uv pip install -r requirements_dev.txt ``` ## Running the tests From within your virtual environment: ``` $ pytest ``` ## Running pre-commit Before sending a PR ensure the formatting is correct: ``` $ pre-commit ``` ## Contributing Committing the change will run all necessary formatting, type checking, and linting. Prefer small PRs to make reviews easy to manage. allenporter-ical-fe8800b/DESIGN.md000066400000000000000000000120231510550726100166600ustar00rootroot00000000000000# Design ## Data Model The calendar data model is described using [pydantic](https://github.com/samuelcolvin/pydantic) models. Pydantic allows expressing effectively a dataclass with types and validation rules, which are reused across components. The data model mirrors the rfc5545 spec with a separate object for each type of component e.g. a calendar, event, todo, etc. ## Parsing and Encoding The rfc5545 spec defines a text file format, and the overall structure defines a series of components (e.g. a calendar, an event, a todo) and properties (e.g. a summary, start date and time, due date, category). Properties may additionally have parameters such as a timezone or alternative text display. Components have a hierarchy (e.g. a calendar component has an event sub-component). The ical library uses [pyparsing](https://github.com/pyparsing/pyparsing) to create a very simple grammar for rfc5545, converting the individual lines of an ics file (called "contentlines") into a structured `ParseResult` object which has a dictionary of fields. The ical library then iterates through each contentline and builds a stack to manage components and subcomponents, parses individual properties and parameters associated with the active component, then returns a `ParsedComponent` which contains other components and properties. At this point we have a tree of components and properties, but have not yet interpreted the meaning. The data model is parsed using [pydantic](https://github.com/samuelcolvin/pydantic) and has parsing and validation rules for each type of data. That is, the library has a bridge between strongly typed rfc5545 properties (e.g. `DATE`, `DATE-TIME`) and python types (e.g. `datetime.date`, `datetime.datetime`). Where possible, the built in pydantic encoding and decoding is used, however ical makes heavy use of custom root validators to perform a lot of the type mapping. Additionally, we want to be able to support parsing the calendar data model from the output of the parser as well as parsing values supplied by the user when creating objects manually. ## Encoding Encoding is the opposite of parsing, converting the pydantic data model back into an rfc5545 text file. The `IcsCalendarStream` model uses both the internal json encoding as well as custom encoding built on top to handle everything. The custom logic is needed since a single field in a pydantic model may be a complex value in the ics output (e.g. containing property parameters). The json encoding using pydantic encoders could be removed in the future, relying on entirely custom components, but for now it is kind of nice to reuse even if there are extra layers on top adding complexity. ## Recurrence The design for recurrence was based on the [design guidance](https://github.com/bmoeskau/Extensible/blob/master/recurrence-overview.md) from Calendar Pro. ### Application Goals The motivation is to support most simple use cases (e.g. home and small business applications) that require repeating events such as daily, weekly, monthly. The other lesser used aspects of the rfc5545 recurrence format like secondly, minutely, hourly or yearly are not needed for these types of use cases. There are performance implications based on possible event storage and event generation trade-offs. The initial approach will be to design for simplicity first, targeting smaller calendars (e.g. tens of recurring events, not thousands) and may layer in performance optimizations such as caching later, under the existing APIs. ### Recurrence Format Like other components in this library, the recurrence format is parsed into a data object using pydantic. This library has no additional internal storage. An rrule is stored as a column of an event. ### Event Generation The `Timeline` interface is used to iterate over events, and can also work to abstract away the details of recurring events. Recurring events may repeat indefinitely, imply the existing iterator only based may need careful consideration e.g. it can't serialize all events into the sorted heapq. The python library `dateutil` has an [rrule](https://dateutil.readthedocs.io/en/stable/rrule.html) module with a lightweight and complete implementation of recurrence rules. Events are generated using a timeline fed by bunch of iterators. There is one iterator for all non-recurring events, then a separate iterator for each recurring event. A merged iterator peeks into the input of each iterator to decide which one to pull from when determining the next item in the iterator. An individual instance of a recurring event is generated with the same `UID`, but with a different `RECURRENCE_ID` based on the start time of that instance. ### Recurrence Editing An entire series may be modified by modifying the original event without referencing a `RECURRENCE_ID`. A `RECURRENCE_ID` can refer to a specific instance in the series, or with `THIS_AND_FUTURE` to apply to forward looking events. When modifying an instance, a new copy of the event is created for that instance and the original event representing the whole series is modified to exclude the edited instance. allenporter-ical-fe8800b/LICENSE000066400000000000000000000261351510550726100164030ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. allenporter-ical-fe8800b/README.md000066400000000000000000000073231510550726100166530ustar00rootroot00000000000000This is an iCalendar rfc 5545 implementation in python. The goal of this project is to offer a calendar library that fills gaps in other widely used calendar libraries such as: - Relevant and practical features needed for building a calendar application -- namely recurring events. - Simple APIs that are straight forward to use - High quality code base with high test coverage and regular releases. ical's main focus is on simplicity. Internally, this library uses other existing data parsing libraries making it easy to support as much as possible of rfc5545. It is not a goal to support everything exhaustively (e.g. enterprise features), however, the simplicity of the implementation makes it easy to do so. The package has high coverage, and high test coverage, and is easy to extend with new rfc5545 properties. This packages uses semantic versioning, and releases often, and works on recent python versions. See [documentation](https://allenporter.github.io/ical/) for full quickstart and API reference. # Quickstart The example below creates a Calendar, then adds an all day event to the calendar, then iterates over all events on the calendar. ```python from datetime import date from ical.calendar import Calendar from ical.event import Event calendar = Calendar() calendar.events.append( Event(summary="Event summary", start=date(2022, 7, 3), end=date(2022, 7, 4)), ) for event in calendar.timeline: print(event.summary) ``` # Reading ics files This example parses an .ics file from disk and creates a `ical.calendar.Calendar` object, then prints out the events in order: ```python from pathlib import Path from ical.calendar_stream import IcsCalendarStream from ical.exceptions import CalendarParseError filename = Path("example/calendar.ics") with filename.open() as ics_file: try: calendar = IcsCalendarStream.calendar_from_ics(ics_file.read()) except CalendarParseError as err: print(f"Failed to parse ics file '{str(filename)}': {err}") else: print([event.summary for event in calendar.timeline]) ``` # Writing ics files This example writes a calendar object to an ics output file: ```python from pathlib import Path from ical.calendar_stream import IcsCalendarStream filename = Path("example/output.ics") with filename.open() as ics_file: ics_file.write(IcsCalendarStream.calendar_to_ics(calendar)) ``` # Application-level APIs The above APIs are used for lower level interaction with calendar components, however applications require a higher level interface to manage some of the underlying complexity. The `ical.store` library is used to manage state at a higher level (e.g. ensuring timezones are created properly) or handling edits to recurring events. # Recurring events A calendar event may be recurring (e.g. weekly, monthly, etc). Recurring events are represented in a `ical.calendar.Calendar` with a single `ical.event.Event` object, however when observed through a `ical.timeline.Timeline` will be expanded based on the recurrence rule. See the `rrule`, `rdate`, and `exdate` fields on the `ical.event.Event` for more details. # Related Work There are other python rfc5545 implementations that are more mature, and having been around for many years, are still active, and served as reference implementations for this project: - Ics.py - [github](https://github.com/ics-py/ics-py) [docs](https://icspy.readthedocs.io/en/stable/) - Since 2013 - icalendar [github](https://github.com/collective/icalendar) [docs](https://icalendar.readthedocs.io/) - Since 2005 You may prefer these projects if you want something that changes less often or if you require a non-modern version of python and if you don't mind patching recurring events on top yourself e.g. using `python-recurring-ical-events`. allenporter-ical-fe8800b/ical/000077500000000000000000000000001510550726100162775ustar00rootroot00000000000000allenporter-ical-fe8800b/ical/__init__.py000066400000000000000000000004441510550726100204120ustar00rootroot00000000000000""" .. include:: ../README.md """ __all__ = [ "alarm", "calendar", "calendar_stream", "event", "freebusy", "journal", "store", "timeline", "timespan", "timezone", "todo", "types", "tzif", "exceptions", "util", "diagnostics", ] allenporter-ical-fe8800b/ical/alarm.py000066400000000000000000000065161510550726100177550ustar00rootroot00000000000000"""Alarm information for calendar components.""" import datetime import enum from typing import Optional, Self, Union from pydantic import Field, field_serializer, model_validator from .component import ComponentModel from .parsing.property import ParsedProperty from .types import CalAddress from .types.data_types import serialize_field class Action(str, enum.Enum): """Type of actioninvoked when alarm is triggered.""" AUDIO = "AUDIO" """An alarm that causes sound to be played to alert the user. The attachment is a sound resource, or a fallback is used. """ DISPLAY = "DISPLAY" """An alarm that displays the description text to the user.""" EMAIL = "EMAIL" """An email is composed and delivered to the attendees. The description is the body of the message, summary is the subject, and attachments are email attachments. """ class Alarm(ComponentModel): """An alarm component for a calendar. The action (e.g. AUDIO, DISPLAY, EMAIL) determine which properties are also specified. """ action: str """Action to be taken when the alarm is triggered.""" trigger: Union[datetime.timedelta, datetime.datetime] """May be either a relative time or absolute time.""" duration: Optional[datetime.timedelta] = None """A duration in time for the alarm. If duration is specified then repeat must also be specified. """ repeat: Optional[int] = None """The number of times an alarm should be repeated. If repeat is specified then duration must also be specified. """ # # Properties for DISPLAY and EMAIL actions # description: Optional[str] = None """A description of the notification or email body.""" # # Properties for EMAIL actions # summary: Optional[str] = None """A summary for the email action.""" attendees: list[CalAddress] = Field(alias="attendee", default_factory=list) """Email recipients for the alarm.""" extras: list[ParsedProperty] = Field(default_factory=list) # Future properties: # - attach @model_validator(mode="after") def parse_display_required_fields(self) -> Self: """Validate required fields for display actions.""" action = self.action if action != Action.DISPLAY: return self if self.description is None: raise ValueError(f"Description value is required for action {action}") return self @model_validator(mode="after") def parse_email_required_fields(self) -> Self: """Validate required fields for email actions.""" action = self.action if action != Action.EMAIL: return self if self.description is None: raise ValueError(f"Description value is required for action {action}") if self.summary is None: raise ValueError(f"Summary value is required for action {action}") return self @model_validator(mode="after") def parse_repeat_duration(self) -> Self: """Assert the relationship between repeat and duration.""" if (self.duration is None) != (self.repeat is None): raise ValueError( "Duration and Repeat must both be specified or both omitted" ) return self serialize_fields = field_serializer("*")(serialize_field) # type: ignore[pydantic-field] allenporter-ical-fe8800b/ical/calendar.py000066400000000000000000000121471510550726100204270ustar00rootroot00000000000000"""The Calendar component.""" from __future__ import annotations import datetime import itertools import logging from typing import Optional, Any import zoneinfo from pydantic import Field, field_serializer, model_validator from ical.types.data_types import serialize_field from .component import ComponentModel from .event import Event from .freebusy import FreeBusy from .journal import Journal from .types.date_time import TZID from .parsing.property import ParsedProperty from .timeline import Timeline, calendar_timeline from .timezone import Timezone, TimezoneModel, IcsTimezoneInfo from .todo import Todo from .util import local_timezone, prodid_factory from .tzif import timezoneinfo from .compat import timezone_compat _LOGGER = logging.getLogger(__name__) _VERSION = "2.0" # Components that may contain TZID objects _TZID_COMPONENTS = ["vevent", "vtodo", "vjournal", "vfreebusy"] class Calendar(ComponentModel): """A sequence of calendar properties and calendar components.""" calscale: Optional[str] = None method: Optional[str] = None prodid: str = Field(default_factory=lambda: prodid_factory()) version: str = Field(default_factory=lambda: _VERSION) # # Calendar components # events: list[Event] = Field(alias="vevent", default_factory=list) """Events associated with this calendar.""" todos: list[Todo] = Field(alias="vtodo", default_factory=list) """Todos associated with this calendar.""" journal: list[Journal] = Field(alias="vjournal", default_factory=list) """Journals associated with this calendar.""" freebusy: list[FreeBusy] = Field(alias="vfreebusy", default_factory=list) """Free/busy objects associated with this calendar.""" timezones: list[Timezone] = Field(alias="vtimezone", default_factory=list) """Timezones associated with this calendar.""" # Unknown or unsupported properties extras: list[ParsedProperty] = Field(default_factory=list) @property def timeline(self) -> Timeline: """Return a timeline view of events on the calendar. All day events are returned as if the attendee is viewing from UTC time. """ return self.timeline_tz() def timeline_tz(self, tzinfo: datetime.tzinfo | None = None) -> Timeline: """Return a timeline view of events on the calendar. All events are returned as if the attendee is viewing from the specified timezone. For example, this affects the order that All Day events are returned. """ return calendar_timeline(self.events, tzinfo=tzinfo or local_timezone()) @model_validator(mode="before") @classmethod def _propagate_timezones(cls, values: dict[str, Any]) -> dict[str, Any]: """Propagate timezone information down to date-time objects. This results in parsing the timezones twice: Once here, then once again in the calendar itself. This does imply that if a vtimezone object is changed live, the DATE-TIME objects are not updated. We first update the timezone objects using another pydantic model just for parsing and propagating here (TimezoneModel). We then walk through all DATE-TIME objects referenced by components and lookup any TZID property parameters, converting them to a datetime.tzinfo object. The DATE-TIME parser will use this instead of the TZID string. We prefer to use any python timezones when present. """ # Run only when initially parsing a VTIMEZONE if "vtimezone" not in values: return values # First parse the timezones out of the calendar, ignoring everything else timezone_model = TimezoneModel.model_validate(values) system_tzids = timezoneinfo.available_timezones() tzinfos: dict[str, datetime.tzinfo] = { timezone.tz_id: IcsTimezoneInfo.from_timezone(timezone) for timezone in timezone_model.timezones if timezone.tz_id not in system_tzids } if not timezone_model.timezones: return values # Replace any TZID objects with a reference to tzinfo from this calendar. The # DATE-TIME parser will use that if present. _LOGGER.debug("Replacing timezone (num %d) references in events", len(tzinfos)) components = itertools.chain.from_iterable( [values.get(component, []) for component in _TZID_COMPONENTS] ) for event in components: for field_values in event.values(): for value in field_values or []: if not isinstance(value, ParsedProperty): continue if ( not (tzid_param := value.get_parameter(TZID)) or not tzid_param.values ): continue if isinstance(tzid_param.values[0], str) and ( tzinfo := tzinfos.get(tzid_param.values[0]) ): tzid_param.values = [tzinfo] return values serialize_fields = field_serializer("*")(serialize_field) # type: ignore[pydantic-field] allenporter-ical-fe8800b/ical/calendar_stream.py000066400000000000000000000057221510550726100220030ustar00rootroot00000000000000"""The core, a collection of Calendar and Scheduling objects. This is an example of parsing an ics file as a stream of calendar objects: ```python from pathlib import Path from ical.calendar_stream import IcsCalendarStream filename = Path("example/calendar.ics") with filename.open() as ics_file: stream = IcsCalendarStream.from_ics(ics_file.read()) print("File contains %s calendar(s)", len(stream.calendars)) ``` You can encode a calendar stream as ics content calling the `ics()` method on the `IcsCalendarStream`: ```python from pathlib import Path filename = Path("/tmp/output.ics") with filename.open(mode="w") as ics_file: ics_file.write(stream.ics()) ``` """ # mypy: allow-any-generics from __future__ import annotations import logging from pydantic import Field, field_serializer from .calendar import Calendar from .component import ComponentModel from .parsing.component import encode_content, parse_content from .types.data_types import serialize_field from .exceptions import CalendarParseError from pydantic import ConfigDict _LOGGER = logging.getLogger(__name__) class CalendarStream(ComponentModel): """A container that is a collection of calendaring information. This object supports parsing an rfc5545 calendar file, but does not support encoding. See `IcsCalendarStream` instead for encoding ics files. """ calendars: list[Calendar] = Field(alias="vcalendar", default_factory=list) @classmethod def from_ics(cls, content: str) -> "CalendarStream": """Factory method to create a new instance from an rfc5545 iCalendar content.""" components = parse_content(content) result: dict[str, list] = {"vcalendar": []} for component in components: result.setdefault(component.name, []) result[component.name].append(component.as_dict()) _LOGGER.debug("Parsing object %s", result) return cls(**result) def ics(self) -> str: """Encode the calendar stream as an rfc5545 iCalendar Stream content.""" return encode_content(self.__encode_component_root__().components) class IcsCalendarStream(CalendarStream): """A calendar stream that supports parsing and encoding ICS.""" @classmethod def calendar_from_ics(cls, content: str) -> Calendar: """Load a single calendar from an ics string.""" stream = cls.from_ics(content) if len(stream.calendars) == 1: return stream.calendars[0] if len(stream.calendars) == 0: return Calendar() raise CalendarParseError("Calendar Stream had more than one calendar") @classmethod def calendar_to_ics(cls, calendar: Calendar) -> str: """Serialize a calendar as an ICS stream.""" stream = cls(vcalendar=[calendar]) return stream.ics() model_config = ConfigDict( validate_assignment=True, populate_by_name=True, ) serialize_fields = field_serializer("*")(serialize_field) # type: ignore[pydantic-field] allenporter-ical-fe8800b/ical/compat/000077500000000000000000000000001510550726100175625ustar00rootroot00000000000000allenporter-ical-fe8800b/ical/compat/__init__.py000066400000000000000000000003421510550726100216720ustar00rootroot00000000000000"""Compatibility layer for fixing invalid ical files. This module provides a compatibility layer for handling invalid iCalendar files. """ from .make_compat import enable_compat_mode __all__ = [ "enable_compat_mode", ] allenporter-ical-fe8800b/ical/compat/make_compat.py000066400000000000000000000027001510550726100224130ustar00rootroot00000000000000"""Compatibility layer for Office 365 and Exchange Server iCalendar files. This module provides a context manager that can allow invalid calendar files to be parsed. """ import contextlib from collections.abc import Generator import logging import re from . import timezone_compat _LOGGER = logging.getLogger(__name__) # Capture group that extracts the PRODID from the ics content. _PRODID_RE = r"PRODID:(?P.*[^\\r\\n]+)" _EXCHANGE_PRODID = "Microsoft Exchange Server" def _get_prodid(ics: str) -> str | None: """Extract the PRODID from the iCalendar content.""" match = re.search(_PRODID_RE, ics) if match: _LOGGER.debug("Extracted PRODID: %s", match) return match.group("prodid") return None @contextlib.contextmanager def enable_compat_mode(ics: str) -> Generator[str]: """Enable compatibility mode to fix known broken calendar content.""" # Check if the PRODID is from Microsoft Exchange Server prodid = _get_prodid(ics) if prodid and _EXCHANGE_PRODID in prodid: _LOGGER.debug("Enabling compatibility mode for Microsoft Exchange Server") # Enable compatibility mode for Microsoft Exchange Server with ( timezone_compat.enable_allow_invalid_timezones(), timezone_compat.enable_extended_timezones(), ): yield ics else: _LOGGER.debug("No compatibility mode needed") # No compatibility mode needed yield ics allenporter-ical-fe8800b/ical/compat/timezone_compat.py000066400000000000000000000022011510550726100233240ustar00rootroot00000000000000"""Compatibility layer for allowing extended timezones in iCalendar files.""" from collections.abc import Generator import contextlib import contextvars _invalide_timezones = contextvars.ContextVar("invalid_timezones", default=False) _extended_timezones = contextvars.ContextVar("extended_timezones", default=False) @contextlib.contextmanager def enable_extended_timezones() -> Generator[None]: """Context manager to allow extended timezones in iCalendar files.""" token = _extended_timezones.set(True) try: yield finally: _extended_timezones.reset(token) def is_extended_timezones_enabled() -> bool: """Check if extended timezones are enabled.""" return _extended_timezones.get() @contextlib.contextmanager def enable_allow_invalid_timezones() -> Generator[None]: """Context manager to allow invalid timezones in iCalendar files.""" token = _invalide_timezones.set(True) try: yield finally: _invalide_timezones.reset(token) def is_allow_invalid_timezones_enabled() -> bool: """Check if allowing invalid timezones is enabled.""" return _invalide_timezones.get() allenporter-ical-fe8800b/ical/component.py000066400000000000000000000361641510550726100206650ustar00rootroot00000000000000"""Library for parsing and encoding rfc5545 components with pydantic. The data model returned by the contentlines parsing is a bag of ParsedProperty objects that support all the flexibility of the rfc5545 spec. However in the common case the spec has a lot more flexibility than is needed for handling simple property types e.g. a single summary field that is specified only once. This library helps reduce boilerplate for translating that complex structure into the simpler pydantic data model, and handles custom field types and validators. Just as the pydantic model provides syntax glue for parsing data and associating necessary validators, this is also doing the same thing in the opposite direction to encode back to ICS. """ from __future__ import annotations import copy import datetime import json import logging from typing import TYPE_CHECKING, Any, Union, get_args, get_origin from pydantic import BaseModel, ConfigDict, ValidationError, model_validator from ical.util import get_field_type from .parsing.component import ParsedComponent from .parsing.property import ParsedProperty from .types.data_types import DATA_TYPE from .exceptions import CalendarParseError, ParameterValueError if TYPE_CHECKING: from typing import TypeVar from .event import Event from .journal import Journal from .todo import Todo ModelT = TypeVar("ModelT", bound=Union[Event, Journal, Todo]) ModelV = TypeVar("ModelV", bound=Union[Event, Todo]) _LOGGER = logging.getLogger(__name__) ATTR_VALUE = "VALUE" # Repeated values can either be specified as multiple separate values, but # also some values support repeated values within a single value with a # comma delimiter, listed here. EXPAND_REPEATED_VALUES = { "categories", "classification", "exdate", "rdate", "resources", "freebusy", } def _adjust_recurrence_date( date_value: datetime.datetime | datetime.date, dtstart: datetime.datetime | datetime.date, ) -> datetime.datetime | datetime.date: """Apply fixes to the recurrence rule date.""" if isinstance(dtstart, datetime.datetime): if not isinstance(date_value, datetime.datetime): raise ValueError( "DTSTART was DATE-TIME but UNTIL was DATE: must be the same value type" ) if dtstart.tzinfo is None: if date_value.tzinfo is not None: raise ValueError("DTSTART is date local but UNTIL was not") return date_value if date_value.utcoffset(): raise ValueError("DTSTART had UTC or local and UNTIL must be UTC") return date_value if isinstance(date_value, datetime.datetime): # Fix invalid rules where UNTIL value is DATE-TIME but DTSTART is DATE return date_value.date() return date_value def validate_until_dtstart(self: ModelT) -> ModelT: """Verify the until time and dtstart are the same.""" if not (rule := self.rrule) or not rule.until or not (dtstart := self.dtstart): return self rule.until = _adjust_recurrence_date(rule.until, dtstart) return self def validate_duration_unit(self: ModelV) -> ModelV: """Validate the duration is the appropriate units.""" if not (duration := self.duration): return self dtstart = self.dtstart if type(dtstart) is datetime.date: if duration.seconds != 0: raise ValueError("Event with start date expects duration in days only") if duration < datetime.timedelta(seconds=0): raise ValueError(f"Expected duration to be positive but was {duration}") return self def _as_datetime( date_value: datetime.datetime | datetime.date, dtstart: datetime.datetime, ) -> datetime.datetime: if not isinstance(date_value, datetime.datetime): new_dt = datetime.datetime.combine(date_value, dtstart.time()) return new_dt.replace(tzinfo=dtstart.tzinfo) return date_value def _as_date( date_value: datetime.datetime | datetime.date, dtstart: datetime.date, ) -> datetime.date: if isinstance(date_value, datetime.datetime): return datetime.date.fromordinal(date_value.toordinal()) return date_value def validate_recurrence_dates(self: ModelT) -> ModelT: """Verify the recurrence dates have the correct types.""" if not self.rrule or not (dtstart := self.dtstart): return self is_datetime = isinstance(dtstart, datetime.datetime) validator = _as_datetime if is_datetime else _as_date for field in ("exdate", "rdate"): if not (date_values := self.__dict__.get(field)): continue self.__dict__[field] = [ validator(date_value, dtstart) # type: ignore[arg-type] for date_value in date_values ] return self class ComponentModel(BaseModel): """Abstract class for rfc5545 component model.""" def __init__(self, **data: Any) -> None: try: super().__init__(**data) except ValidationError as err: _LOGGER.debug("Failed to parse component %s", err) message = [ f"Failed to parse calendar {self.__class__.__name__.upper()} component" ] for error in err.errors(): if msg := error.get("msg"): message.append(msg) error_str = ": ".join(message) raise CalendarParseError(error_str, detailed_error=str(err)) from err def copy_and_validate(self, update: dict[str, Any]) -> ComponentModel: """Create a new object with updated values and validate it.""" # Make a deep copy since deletion may update this objects recurrence rules new_item_copy = self.model_copy(update=update, deep=True) # Create a new object using the constructor to ensure we're performing # validation on the new object. return self.__class__(**new_item_copy.model_dump()) @model_validator(mode="before") @classmethod def parse_extra_fields( cls, values: dict[str, list[ParsedProperty | ParsedComponent]] ) -> dict[str, Any]: """Parse extra fields not in the model.""" all_fields: set[str | None] = set() for name, field in cls.model_fields.items(): all_fields |= {field.alias, name} extras: list[ParsedProperty | ParsedComponent] = [] for field_name, value in values.items(): if field_name in all_fields: continue for prop in value: if isinstance(prop, ParsedProperty): extras.append(prop) if extras: values["extras"] = extras return values @model_validator(mode="before") @classmethod def parse_property_values(cls, values: dict[str, Any]) -> dict[str, Any]: """Parse individual ParsedProperty value fields.""" _LOGGER.debug("Parsing value data %s", values) for name, field in cls.model_fields.items(): if field.alias == "extras": continue field_name = field.alias or name if not (value := values.get(field_name)): continue if not (isinstance(value, list) and isinstance(value[0], ParsedProperty)): # The incoming value is not from the parse tree continue if field_name in EXPAND_REPEATED_VALUES: value = cls._expand_repeated_property(value) # Repeated values will accept a list, otherwise truncate to a single # value when repeated is not allowed. annotation = get_field_type(field.annotation) allow_repeated = get_origin(annotation) is list if not allow_repeated and len(value) > 1: raise ValueError(f"Expected one value for field: {name}") field_types = cls._get_field_types(annotation) validated = [cls._parse_property(field_types, prop) for prop in value] values[field_name] = validated if allow_repeated else validated[0] _LOGGER.debug("Completed parsing value data %s", values) return values @classmethod def _parse_property(cls, field_types: list[type], prop: ParsedProperty) -> Any: """Parse an individual field value from a ParsedProperty as the specified types.""" _LOGGER.debug( "Parsing field '%s' with value '%s' as types %s", prop.name, prop.value, field_types, ) errors = [] for sub_type in field_types: try: return cls._parse_single_property(sub_type, prop) except ParameterValueError as err: _LOGGER.debug("Invalid property value of type %s: %s", sub_type, err) raise err except ValueError as err: _LOGGER.debug( "Unable to parse property value as type %s: %s", sub_type, err ) errors.append(str(err)) continue raise ValueError( f"Failed to validate: {prop.value} as {' or '.join(sub_type.__name__ for sub_type in field_types)}, due to: ({errors})" ) @classmethod def _parse_single_property(cls, field_type: type, prop: ParsedProperty) -> Any: """Parse an individual field as a single type.""" if ( value_type := prop.get_parameter_value(ATTR_VALUE) ) and field_type not in DATA_TYPE.disable_value_param: # Property parameter specified a strong type if func := DATA_TYPE.parse_parameter_by_name.get(value_type): _LOGGER.debug("Parsing %s as value type '%s'", prop.name, value_type) return func(prop) # Consider graceful degradation instead in the future raise ValueError( f"Property parameter specified unsupported type: {value_type}" ) if decoder := DATA_TYPE.parse_property_value.get(field_type): _LOGGER.debug("Decoding '%s' as type '%s'", prop.name, field_type) return decoder(prop) _LOGGER.debug("Using '%s' bare property value '%s'", prop.name, prop.value) return prop.value @classmethod def _expand_repeated_property( cls, value: list[ParsedProperty] ) -> list[ParsedProperty]: """Expand properties with repeated values into separate properties.""" result: list[ParsedProperty] = [] for prop in value: if "," in prop.value: for sub_value in prop.value.split(","): sub_prop = copy.deepcopy(prop) sub_prop.value = sub_value result.append(sub_prop) else: result.append(prop) return result @classmethod def _get_field_types(cls, field_type: type) -> list[type]: """Return type to attempt for encoding/decoding based on the field type.""" origin = get_origin(field_type) if origin is list: if not (args := get_args(field_type)): raise ValueError(f"Unable to determine args of type: {field_type}") field_type = args[0] origin = get_origin(field_type) if origin is Union: if not (args := get_args(field_type)): raise ValueError(f"Unable to determine args of type: {field_type}") # get_args does not have a deterministic order, so use the order supplied # in the registry. Ignore None as its not a parseable type. sortable_args = [ (DATA_TYPE.parse_order.get(arg, 0), arg) for arg in args if arg is not type(None) # noqa: E721 ] sortable_args.sort(reverse=True) return [arg for (order, arg) in sortable_args] return [field_type] def __encode_component_root__(self) -> ParsedComponent: """Encode the calendar stream as an rfc5545 iCalendar content.""" # The overall data model hierarchy is created by pydantic and properties # are encoded using the json encoders specific for each type. These are # marshalled through as string values. There are then additional passes # to get the data in to the right final format for ics encoding. model_data = json.loads( self.model_dump_json( by_alias=True, exclude_none=True, context={"ics": True} ) ) # The component name is ignored as we're really only encoding children components return self.__encode_component__(self.__class__.__name__, model_data) @classmethod def __encode_component__( cls, name: str, model_data: dict[str, Any] ) -> ParsedComponent: """Encode this object as a component to prepare for serialization. The data passed in have already been encoded with one pass from the root json encoder. This method takes additional passes to add more field specific encoding, as well as overall component objects. """ parent = ParsedComponent(name=name) for name, field in cls.model_fields.items(): key = field.alias or name values = model_data.get(key) if values is None or key == "extras": continue if not isinstance(values, list): values = [values] annotation = get_field_type(field.annotation) for value in values: for field_type in cls._get_field_types(annotation): if component_encoder := getattr( field_type, "__encode_component__", None ): parent.components.append(component_encoder(key, value)) break else: if prop := cls._encode_property(key, annotation, value): parent.properties.append(prop) return parent @classmethod def _encode_property(cls, key: str, field_type: type, value: Any) -> ParsedProperty: """Encode an individual property for the specified field.""" # A property field may have multiple possible types, like for # a Union. Pick the first type that is able to encode the value. errors = [] for sub_type in cls._get_field_types(field_type): encoded_value: Any | None = None if value_encoder := DATA_TYPE.encode_property_value.get(sub_type): try: encoded_value = value_encoder(value) except ValueError as err: _LOGGER.debug("Encoding failed for property: %s", err) errors.append(str(err)) continue else: encoded_value = value if encoded_value is not None: prop = ParsedProperty(name=key, value=encoded_value) if params_encoder := DATA_TYPE.encode_property_params.get( sub_type, None ): if params := params_encoder(value): prop.params = params return prop raise ValueError(f"Unable to encode property: {value}, errors: {errors}") model_config = ConfigDict( validate_assignment=True, populate_by_name=True, arbitrary_types_allowed=True, ) allenporter-ical-fe8800b/ical/diagnostics.py000066400000000000000000000027771510550726100211750ustar00rootroot00000000000000"""Library for diagnostics or debugging information about calendars.""" from __future__ import annotations from collections.abc import Generator import itertools __all__ = [ "redact_ics", ] COMPONENT_ALLOWLIST = { "BEGIN", "END", "DTSTAMP", "CREATED", "LAST-MODIFIED", "DTSTART", "DTEND", "RRULE", "PRODID", } REDACT = "***" MAX_CONTENTLINES = 5000 def component_sep(contentline: str) -> int: """Return the component prefix index in the string.""" colon = contentline.find(":") semi = contentline.find(";") if colon > -1 and semi > -1: return min(colon, semi) if colon > -1: return colon return semi def redact_contentline(contentline: str, component_allowlist: set[str]) -> str: """Return a redacted version of an ics content line.""" if (i := component_sep(contentline)) and i > -1: component = contentline[0:i] if component in component_allowlist: return contentline return f"{component}:{REDACT}" return REDACT def redact_ics( ics: str, max_contentlines: int = MAX_CONTENTLINES, component_allowlist: set[str] | None = None, ) -> Generator[str, None, None]: """Generate redacted ics file contents one line at a time.""" contentlines = ics.split("\n") for contentline in itertools.islice(contentlines, max_contentlines): if contentline: yield redact_contentline( contentline, component_allowlist or COMPONENT_ALLOWLIST ) allenporter-ical-fe8800b/ical/event.py000066400000000000000000000406021510550726100177740ustar00rootroot00000000000000"""A grouping of component properties that describe a calendar event. An event can be an activity (e.g. a meeting from 8am to 9am tomorrow) grouping of properties such as a summary or a description. An event will take up time on a calendar as an opaque time interval, but can alternatively have transparency set to transparent to prevent blocking of time as busy. An event start and end time may either be a date and time or just a day alone. Events may also span more than one day. Alternatively, an event can have a start and a duration. """ # pylint: disable=unnecessary-lambda from __future__ import annotations import datetime import enum import logging from collections.abc import Iterable from typing import Annotated, Any, Optional, Self, Union from pydantic import BeforeValidator, Field, field_serializer, model_validator from ical.types.data_types import serialize_field from .alarm import Alarm from .component import ( ComponentModel, validate_duration_unit, validate_until_dtstart, validate_recurrence_dates, ) from .iter import RulesetIterable, as_rrule from .parsing.property import ParsedProperty from .timespan import Timespan from .types import ( CalAddress, Classification, Geo, Priority, Recur, RecurrenceId, RequestStatus, Uri, RelatedTo, ) from .util import ( dtstamp_factory, normalize_datetime, parse_date_and_datetime, parse_date_and_datetime_list, uid_factory, ) _LOGGER = logging.getLogger(__name__) class EventStatus(str, enum.Enum): """Status or confirmation of the event set by the organizer.""" CONFIRMED = "CONFIRMED" """Indicates event is definite.""" TENTATIVE = "TENTATIVE" """Indicates event is tentative.""" CANCELLED = "CANCELLED" """Indicates event was cancelled.""" class Event(ComponentModel): """A single event on a calendar. Can either be for a specific day, or with a start time and duration/end time. The dtstamp and uid functions have factory methods invoked with a lambda to facilitate mocking in unit tests. Example: ```python import datetime from ical.event import Event event = Event( dtstart=datetime.datetime(2022, 8, 31, 7, 00, 00), dtend=datetime.datetime(2022, 8, 31, 7, 30, 00), summary="Morning exercise", ) print("The event duration is: ", event.computed_duration) ``` An Event is a pydantic model, so all properties of a pydantic model apply here to such as the constructor arguments, properties to return the model as a dictionary or json, as well as other parsing methods. """ dtstamp: Annotated[ Union[datetime.date, datetime.datetime], BeforeValidator(parse_date_and_datetime), ] = Field(default_factory=lambda: dtstamp_factory()) """Specifies the date and time the event was created.""" uid: str = Field(default_factory=lambda: uid_factory()) """A globally unique identifier for the event.""" # Has an alias of 'start' dtstart: Annotated[ Union[datetime.date, datetime.datetime, None], BeforeValidator(parse_date_and_datetime), ] = Field(default=None) """The start time or start day of the event.""" # Has an alias of 'end' dtend: Annotated[ Union[datetime.date, datetime.datetime, None], BeforeValidator(parse_date_and_datetime), ] = None """The end time or end day of the event. This may be specified as an explicit date. Alternatively, a duration can be used instead. """ duration: Optional[datetime.timedelta] = None """The duration of the event as an alternative to an explicit end date/time.""" summary: Optional[str] = None """Defines a short summary or subject for the event.""" attendees: list[CalAddress] = Field(alias="attendee", default_factory=list) """Specifies participants in a group-scheduled calendar.""" categories: list[str] = Field(default_factory=list) """Defines the categories for an event. Specifies a category or subtype. Can be useful for searching for a particular type of event. """ classification: Optional[Classification] = Field(alias="class", default=None) """An access classification for a calendar event. This provides a method of capturing the scope of access of a calendar, in conjunction with an access control system. """ comment: list[str] = Field(default_factory=list) """Specifies a comment to the calendar user.""" contacts: list[str] = Field(alias="contact", default_factory=list) """Contact information associated with the event.""" created: Optional[datetime.datetime] = None """The date and time the event information was created.""" description: Optional[str] = None """A more complete description of the event than provided by the summary.""" geo: Optional[Geo] = None """Specifies a latitude and longitude global position for the event activity.""" last_modified: Optional[datetime.datetime] = Field( alias="last-modified", default=None ) location: Optional[str] = None """Defines the intended venue for the activity defined by this event.""" organizer: Optional[CalAddress] = None """The organizer of a group-scheduled calendar entity.""" priority: Optional[Priority] = None """Defines the relative priority of the calendar event.""" recurrence_id: Optional[RecurrenceId] = Field(alias="recurrence-id", default=None) """Defines a specific instance of a recurring event. The full range of calendar events specified by a recurrence set is referenced by referring to just the uid. The `recurrence_id` allows reference of an individual instance within the recurrence set. """ related_to: list[RelatedTo] = Field(alias="related-to", default_factory=list) """Used to represent a relationship or reference between events.""" related: list[str] = Field(default_factory=list) """Unused and will be deleted in a future release""" resources: list[str] = Field(default_factory=list) """Defines the equipment or resources anticipated for the calendar event.""" rrule: Optional[Recur] = None """A recurrence rule specification. Defines a rule for specifying a repeated event. The recurrence set is the complete set of recurrence instances for a calendar component (based on rrule, rdate, exdate). The recurrence set is generated by gathering the rrule and rdate properties then excluding any times specified by exdate. The recurrence is generated with the dtstart property defining the first instance of the recurrence set. Typically a dtstart should be specified with a date local time and timezone to make sure all instances have the same start time regardless of time zone changing. """ rdate: Annotated[ list[Union[datetime.date, datetime.datetime]], BeforeValidator(parse_date_and_datetime_list), ] = Field(default_factory=list) """Defines the list of date/time values for recurring events. Can appear along with the rrule property to define a set of repeating occurrences of the event. The recurrence set is the complete set of recurrence instances for a calendar component (based on rrule, rdate, exdate). The recurrence set is generated by gathering the rrule and rdate properties then excluding any times specified by exdate. """ exdate: Annotated[ list[Union[datetime.date, datetime.datetime]], BeforeValidator(parse_date_and_datetime_list), ] = Field(default_factory=list) """Defines the list of exceptions for recurring events. The exception dates are used in computing the recurrence set. The recurrence set is the complete set of recurrence instances for a calendar component (based on rrule, rdate, exdate). The recurrence set is generated by gathering the rrule and rdate properties then excluding any times specified by exdate. """ request_status: Optional[RequestStatus] = Field( default=None, alias="request-status", ) sequence: Optional[int] = None """The revision sequence number in the calendar component. When an event is created, its sequence number is 0. It is monotonically incremented by the organizer's calendar user agent every time a significant revision is made to the calendar event. """ status: Optional[EventStatus] = None """Defines the overall status or confirmation of the event. In a group-scheduled calendar, used by the organizer to provide a confirmation of the event to attendees. """ transparency: Optional[str] = Field(alias="transp", default=None) """Defines whether or not an event is transparent to busy time searches.""" url: Optional[Uri] = None """Defines a url associated with the event. May convey a location where a more dynamic rendition of the calendar event information associated with the event can be found. """ # Unknown or unsupported properties extras: list[ParsedProperty] = Field(default_factory=list) alarm: list[Alarm] = Field(alias="valarm", default_factory=list) """A grouping of reminder alarms for the event.""" def __init__(self, **data: Any) -> None: """Initialize a Calendar Event. This method accepts keyword args with field names on the Calendar such as `summary`, `start`, `end`, `description`, etc. """ if "start" in data: data["dtstart"] = data.pop("start") if "end" in data: data["dtend"] = data.pop("end") super().__init__(**data) @property def start(self) -> datetime.datetime | datetime.date: """Return the start time for the event.""" assert self.dtstart is not None return self.dtstart @property def end(self) -> datetime.datetime | datetime.date: """Return the end time for the event.""" if self.duration: return self.start + self.duration if self.dtend: return self.dtend if isinstance(self.start, datetime.datetime): return self.start return self.start + datetime.timedelta(days=1) @property def start_datetime(self) -> datetime.datetime: """Return the events start as a datetime in UTC""" return normalize_datetime(self.start).astimezone(datetime.timezone.utc) @property def end_datetime(self) -> datetime.datetime: """Return the events end as a datetime in UTC.""" return normalize_datetime(self.end).astimezone(datetime.timezone.utc) @property def computed_duration(self) -> datetime.timedelta: """Return the event duration.""" if self.duration is not None: return self.duration return self.end - self.start @property def timespan(self) -> Timespan: """Return a timespan representing the event start and end.""" return Timespan.of(self.start, self.end) def timespan_of(self, tzinfo: datetime.tzinfo) -> Timespan: """Return a timespan representing the event start and end.""" return Timespan.of( normalize_datetime(self.start, tzinfo), normalize_datetime(self.end, tzinfo) ) def starts_within(self, other: "Event") -> bool: """Return True if this event starts while the other event is active.""" return self.timespan.starts_within(other.timespan) def ends_within(self, other: "Event") -> bool: """Return True if this event ends while the other event is active.""" return self.timespan.ends_within(other.timespan) def intersects(self, other: "Event") -> bool: """Return True if this event overlaps with the other event.""" return self.timespan.intersects(other.timespan) def includes(self, other: "Event") -> bool: """Return True if the other event starts and ends within this event.""" return self.timespan.includes(other.timespan) def is_included_in(self, other: "Event") -> bool: """Return True if this event starts and ends within the other event.""" return self.timespan.is_included_in(other.timespan) def __lt__(self, other: Any) -> bool: if not isinstance(other, Event): return NotImplemented return self.timespan < other.timespan def __gt__(self, other: Any) -> bool: if not isinstance(other, Event): return NotImplemented return self.timespan > other.timespan def __le__(self, other: Any) -> bool: if not isinstance(other, Event): return NotImplemented return self.timespan <= other.timespan def __ge__(self, other: Any) -> bool: if not isinstance(other, Event): return NotImplemented return self.timespan >= other.timespan @property def recurring(self) -> bool: """Return true if this event is recurring. A recurring event is typically evaluated specially on the timeline. The data model has a single event, but the timeline evaluates the recurrence to expand and copy the event to multiple places on the timeline using `as_rrule`. """ if self.rrule or self.rdate: return True return False def as_rrule(self) -> Iterable[datetime.datetime | datetime.date] | None: """Return an iterable containing the occurrences of a recurring event. A recurring event is typically evaluated specially on the timeline. The data model has a single event, but the timeline evaluates the recurrence to expand and copy the event to multiple places on the timeline. This is only valid for events where `recurring` is True. """ return as_rrule(self.rrule, self.rdate, self.exdate, self.dtstart) @model_validator(mode="before") @classmethod def _inspect_date_types(cls, values: dict[str, Any]) -> dict[str, Any]: """Debug the date and date/time values of the event.""" dtstart = values.get("dtstart") dtend = values.get("dtend") if not dtstart or not dtend: return values _LOGGER.debug("Found initial values dtstart=%s, dtend=%s", dtstart, dtend) return values _validate_until_dtstart = model_validator(mode="after")(validate_until_dtstart) _validate_recurrence_dates = model_validator(mode="after")( validate_recurrence_dates ) @model_validator(mode="after") def _validate_date_types(self) -> Self: """Validate that start and end values are the same date or datetime type.""" dtstart = self.dtstart dtend = self.dtend if not dtstart or not dtend: return self if isinstance(dtstart, datetime.datetime): if not isinstance(dtend, datetime.datetime): _LOGGER.debug("Unexpected data types for values: %s", self) raise ValueError( f"Unexpected dtstart value '{dtstart}' was datetime but " f"dtend value '{dtend}' was not datetime" ) elif isinstance(dtstart, datetime.date): if isinstance(dtend, datetime.datetime): raise ValueError( f"Unexpected dtstart value '{dtstart}' was date but " f"dtend value '{dtend}' was datetime" ) return self @model_validator(mode="after") def _validate_datetime_timezone(self) -> Self: """Validate that start and end values have the same timezone information.""" if ( not (dtstart := self.dtstart) or not (dtend := self.dtend) or not isinstance(dtstart, datetime.datetime) or not isinstance(dtend, datetime.datetime) ): return self if dtstart.tzinfo is None and dtend.tzinfo is not None: raise ValueError( f"Expected end datetime value in localtime but was {dtend}" ) if dtstart.tzinfo is not None and dtend.tzinfo is None: raise ValueError(f"Expected end datetime with timezone but was {dtend}") return self @model_validator(mode="after") def _validate_one_end_or_duration(self) -> Self: """Validate that only one of duration or end date may be set.""" if self.dtend and self.duration: raise ValueError("Only one of dtend or duration may be set.") return self _validate_duration_unit = model_validator(mode="after")(validate_duration_unit) serialize_fields = field_serializer("*")(serialize_field) # type: ignore[pydantic-field] allenporter-ical-fe8800b/ical/exceptions.py000066400000000000000000000043011510550726100210300ustar00rootroot00000000000000"""Exceptions for ical library.""" class CalendarError(Exception): """Base exception for all ical errors.""" class CalendarParseError(CalendarError): """Exception raised when parsing an ical string. The 'message' attribute contains a human-readable message about the error that occurred. The 'detailed_error' attribute can provide additional information about the error, such as a stack trace or detailed parsing information, useful for debugging purposes. """ def __init__(self, message: str, *, detailed_error: str | None = None) -> None: """Initialize the CalendarParseError with a message.""" super().__init__(message) self.message = message self.detailed_error = detailed_error class ParameterValueError(ValueError): """Exception raised when validating a datetime. When validating a ParsedProperty, it may not be known what data-type the result should be, so multiple validators may be called. We must distinguish between "this property does not look like this data-type" from "this property should be this data-type, but it is invalid". The former should raise a ValueError and the latter ParameterValueError. The motivating example is "DTSTART;TZID=GMT:20250601T171819" as either datetime or date. It fails to be a datetime because of an unrecognized timezone, and fails to be a date, because it is a datetime. Rather than continue trying to validate it as a date, raise ParameterValueError to stop, and simply return a single error. """ class RecurrenceError(CalendarError): """Exception raised when evaluating a recurrence rule. Recurrence rules have complex logic and it is common for there to be invalid dates or bugs, so this special exception exists to help provide additional debug data to find the source of the issue. Often `dateutil.rrule` has limitations and ical has to work around them by providing special wrapping libraries. """ class StoreError(CalendarError): """Exception thrown by a Store.""" class EventStoreError(StoreError): """Exception thrown by the EventStore.""" class TodoStoreError(StoreError): """Exception thrown by the TodoStore.""" allenporter-ical-fe8800b/ical/freebusy.py000066400000000000000000000106721510550726100205030ustar00rootroot00000000000000"""A set of properties that describes a free/busy times.""" # pylint: disable=unnecessary-lambda from __future__ import annotations import datetime import logging from typing import Annotated, Any, Optional, Union from pydantic import BeforeValidator, Field, field_serializer, field_validator from ical.types.data_types import serialize_field from .component import ComponentModel from .parsing.property import ParsedProperty from .types import CalAddress, Period, RequestStatus, Uri from .util import ( dtstamp_factory, normalize_datetime, parse_date_and_datetime, uid_factory, ) _LOGGER = logging.getLogger(__name__) class FreeBusy(ComponentModel): """A single free/busy entry on a calendar.""" dtstamp: Annotated[ Union[datetime.date, datetime.datetime], BeforeValidator(parse_date_and_datetime), ] = Field(default_factory=lambda: dtstamp_factory()) """Last revision date.""" uid: str = Field(default_factory=lambda: uid_factory()) """The persistent globally unique identifier.""" attendees: list[CalAddress] = Field(alias="attendee", default_factory=list) """The user whose free/busy time is represented.""" comment: list[str] = Field(default_factory=list) """Non-processing information intended to provide comments to the calendar user.""" contacts: list[str] = Field(alias="contact", default_factory=list) """Contact information associated with this component.""" # Has an alias of 'start' dtstart: Annotated[ Union[datetime.date, datetime.datetime, None], BeforeValidator(parse_date_and_datetime), ] = Field(default=None) """Start of the time range covered by this component.""" # Has an alias of 'end' dtend: Annotated[ Union[datetime.date, datetime.datetime, None], BeforeValidator(parse_date_and_datetime), ] = None """End of the time range covered by this component.""" freebusy: list[Period] = Field(default_factory=list) """The free/busy intervals.""" organizer: Optional[CalAddress] = None """The calendar user who requested free/busy information.""" request_status: Optional[RequestStatus] = Field( default=None, alias="request-status", ) """Return code for the scheduling request.""" sequence: Optional[int] = None """The revision sequence number of this calendar component.""" url: Optional[Uri] = None """The URL associated with this component.""" # Unknown or unsupported properties extras: list[ParsedProperty] = Field(default_factory=list) def __init__(self, **data: Any) -> None: """Initialize Event.""" if "start" in data: data["dtstart"] = data.pop("start") if "end" in data: data["dtend"] = data.pop("end") super().__init__(**data) @property def start(self) -> datetime.datetime | datetime.date | None: """Return the start time for the event.""" return self.dtstart @property def end(self) -> datetime.datetime | datetime.date | None: """Return the end time for the event.""" return self.dtend @property def start_datetime(self) -> datetime.datetime | None: """Return the events start as a datetime.""" if not self.start: return None return normalize_datetime(self.start).astimezone(tz=datetime.timezone.utc) @property def end_datetime(self) -> datetime.datetime | None: """Return the events end as a datetime.""" if not self.end: return None return normalize_datetime(self.end).astimezone(tz=datetime.timezone.utc) @property def computed_duration(self) -> datetime.timedelta | None: """Return the event duration.""" if not self.end or not self.start: return None return self.end - self.start @field_validator("freebusy") @classmethod def verify_freebusy_utc(cls, values: list[Period]) -> list[Period]: """Validate that the free/busy periods must be in UTC.""" _LOGGER.info("verify_freebusy_utc") for value in values: if not value.start: continue if ( offset := value.start.utcoffset() ) is None or offset.total_seconds() != 0: raise ValueError(f"Freebusy time must be in UTC format: {value}") return values serialize_fields = field_serializer("*")(serialize_field) # type: ignore[pydantic-field] allenporter-ical-fe8800b/ical/iter.py000066400000000000000000000353461510550726100176270ustar00rootroot00000000000000"""Library for iterators used in ical. These iterators are primarily used for implementing recurrence rules where an object should be returned for a series of date/time, with some modification based on that date/time. Additionally, it is often necessary to handle multiple recurrence rules together as a single view of recurring date/times. Some of the iterators here are primarily used to extend functionality of `dateutil.rrule` and work around some of the limitations when building real world calendar applications such as the ability to make recurring all day events. Most of the things in this library should not be consumed directly by calendar users, but instead for implementing another calendar library as they support behind the scenes things like timelines. These internals may be subject to a higher degree of backwards incompatibility due to the internal nature. """ from __future__ import annotations import datetime import heapq import logging from abc import ABC, abstractmethod from collections.abc import Callable, Iterable, Iterator from typing import Any, Generic, TypeVar, Union, cast from dateutil import rrule from .timespan import Timespan from .util import normalize_datetime from .types.recur import Recur from .exceptions import CalendarParseError, RecurrenceError __all__ = [ "RecurrenceError", "RulesetIterable", "SortableItemTimeline", "SortableItem", "SortableItemValue", "SortedItemIterable", "MergedIterable", "RecurIterable", "ItemAdapter", "LazySortableItem", ] _LOGGER = logging.getLogger(__name__) T = TypeVar("T") K = TypeVar("K") ItemAdapter = Callable[[Union[datetime.datetime, datetime.date]], T] """An adapter for an object in a sorted container (iterator). The adapter is invoked with the date/time of the current instance and the callback returns an object at that time (e.g. event with updated time) """ class SortableItem(Generic[K, T], ABC): """A SortableItem is used to sort an item by an arbitrary key. This object is used as a holder of the actual event or recurring event such that the sort key used is independent of the event to avoid extra copies and comparisons of a large event object. """ def __init__(self, key: K) -> None: """Initialize SortableItem.""" self._key = key @property def key(self) -> K: """Return the sort key.""" return self._key @property @abstractmethod def item(self) -> T: """Return the underlying item.""" def __lt__(self, other: Any) -> bool: """Compare sortable items together.""" if not isinstance(other, SortableItem): return NotImplemented return cast(bool, self._key < other.key) SpanOrderedItem = SortableItem[Timespan, T] """A sortable item with a timespan as the key.""" class SortableItemValue(SortableItem[K, T]): """Concrete value implementation of SortableItem.""" def __init__(self, key: K, value: T) -> None: """Initialize SortableItemValue.""" super().__init__(key) self._value = value @property def item(self) -> T: """Return the underlying item.""" return self._value class LazySortableItem(SortableItem[K, T]): """A SortableItem that has its value built lazily.""" def __init__( self, key: K, item_cb: Callable[[], T], ) -> None: """Initialize SortableItemValue.""" super().__init__(key) self._item_cb = item_cb @property def item(self) -> T: """Return the underlying item.""" return self._item_cb() class AllDayConverter(Iterable[Union[datetime.date, datetime.datetime]]): """An iterable that converts datetimes to all days events.""" def __init__(self, dt_iter: Iterable[datetime.date | datetime.datetime]): """Initialize AllDayConverter.""" self._dt_iter = dt_iter def __iter__(self) -> Iterator[datetime.date | datetime.datetime]: """Return an iterator with all day events converted.""" for value in self._dt_iter: # Convert back to datetime.date if needed for the original event yield datetime.date.fromordinal(value.toordinal()) def _defloat( dt: datetime.datetime | datetime.date, ) -> datetime.datetime | datetime.date: """Convert a datetime to a floating time.""" if isinstance(dt, datetime.datetime) and dt.tzinfo is not None: return dt.replace(tzinfo=None) return dt class RulesetIterable(Iterable[Union[datetime.datetime, datetime.date]]): """A wrapper around the dateutil ruleset library to workaround limitations. The `dateutil.rrule` library does not allow iteration in terms of dates and requires additional workarounds to support them properly: namely converting back and forth between a datetime and a date. It is also very common to have the library throw errors that it can't compare properly between dates and times, which are difficult to debug. This wrapper is meant to assist with that. """ _converter: Callable[ [Iterable[Union[datetime.date, datetime.datetime]]], Iterable[Union[datetime.date, datetime.datetime]], ] _defloat: Callable[ [datetime.datetime | datetime.date], datetime.datetime | datetime.date ] def __init__( self, dtstart: datetime.datetime | datetime.date, recur: list[Iterable[datetime.datetime | datetime.date]], rdate: list[datetime.datetime | datetime.date], exdate: list[datetime.datetime | datetime.date], ) -> None: """Create the RulesetIterable.""" self._dtstart = dtstart self._rrule = recur self._rdate = rdate self._exdate = exdate # dateutil.rrule will convert all input values to datetime even if the # input value is a date. If needed, convert back to a date so that # comparisons between exdate/rdate as a date in the rruleset will # be in the right format. if not isinstance(dtstart, datetime.datetime): self._converter = AllDayConverter else: self._converter = lambda x: x if ( isinstance(self._dtstart, datetime.datetime) and self._dtstart.tzinfo is None ): self._defloat = _defloat else: self._defloat = lambda x: x def _ruleset(self) -> Iterable[datetime.datetime | datetime.date]: """Create a dateutil.rruleset.""" ruleset = rrule.rruleset() for rule in self._rrule: ruleset.rrule(self._converter(rule)) # type: ignore[arg-type] for rdate in self._rdate: ruleset.rdate(self._defloat(rdate)) for exdate in self._exdate: ruleset.exdate(self._defloat(exdate)) return ruleset def __iter__(self) -> Iterator[datetime.datetime | datetime.date]: """Return an iterator as a traversal over events in chronological order.""" try: for value in self._ruleset(): yield value except TypeError as err: raise RecurrenceError( f"Error evaluating recurrence rule ({self}): {str(err)}" ) from err def __repr__(self) -> str: return ( f"RulesetIterable(dtstart={self._dtstart}, rrule={[str(r) for r in self._rrule]}, " f"rdate={self._rdate}, exdate={self._exdate})" ) class RecurIterable(Iterable[T]): """A series of events from a recurring event. The inputs are a callback that creates objects at a specific date/time, and an iterable of all the relevant date/times (typically a dateutil.rrule or dateutil.rruleset). """ def __init__( self, item_cb: ItemAdapter[T], recur: Iterable[datetime.datetime | datetime.date], ) -> None: """Initialize timeline.""" self._item_cb = item_cb self._recur = recur def __iter__(self) -> Iterator[T]: """Return an iterator as a traversal over events in chronological order.""" for dtvalue in self._recur: yield self._item_cb(dtvalue) class MergedIterator(Iterator[T]): """An iterator with a merged sorted view of the underlying sorted iterators.""" def __init__(self, iters: list[Iterator[T]]): """Initialize MergedIterator.""" self._iters = iters self._heap: list[tuple[T, int]] | None = None def __iter__(self) -> Iterator[T]: """Return this iterator.""" return self def _make_heap(self) -> None: self._heap = [] for iter_index, iterator in enumerate(self._iters): try: next_item = next(iterator) except StopIteration: pass else: heapq.heappush(self._heap, (next_item, iter_index)) def __next__(self) -> T: """Produce the next item from the merged set.""" if self._heap is None: self._make_heap() if not self._heap: raise StopIteration() (item, iter_index) = heapq.heappop(self._heap) iterator = self._iters[iter_index] try: next_item = next(iterator) except StopIteration: pass # Iterator not added back to heap else: heapq.heappush(self._heap, (next_item, iter_index)) return item class MergedIterable(Iterable[T]): """An iterator that merges results from underlying sorted iterables.""" def __init__(self, iters: list[Iterable[T]]) -> None: """Initialize MergedIterable.""" self._iters = iters def __iter__(self) -> Iterator[T]: return MergedIterator([iter(it) for it in self._iters]) class SortedItemIterable(Iterable[SortableItem[K, T]]): """Iterable that returns sortable items in sorted order. This is useful when iterating over a subset of non-recurring events. """ def __init__( self, iterable: Callable[[], Iterable[SortableItem[K, T]]], tzinfo: datetime.tzinfo, ) -> None: """Initialize timeline.""" self._iterable = iterable self._tzinfo = tzinfo def __iter__(self) -> Iterator[SortableItem[K, T]]: """Return an iterator as a traversal over events in chronological order.""" # Using a heap is faster than sorting if the number of events (n) is # much bigger than the number of events we extract from the iterator (k). # Complexity: O(n + k log n). heap: list[SortableItem[K, T]] = [] for item in self._iterable(): heapq.heappush(heap, item) while heap: yield heapq.heappop(heap) class SortableItemTimeline(Iterable[T]): """A set of components on a calendar.""" def __init__(self, iterable: Iterable[SpanOrderedItem[T]]) -> None: self._iterable = iterable def __iter__(self) -> Iterator[T]: """Return an iterator as a traversal over events in chronological order.""" for item in iter(self._iterable): yield item.item def included( self, start: datetime.date | datetime.datetime, end: datetime.date | datetime.datetime, ) -> Iterator[T]: """Return an iterator for all events active during the timespan. The end date is exclusive. """ timespan = Timespan.of(start, end) for item in self._iterable: if item.key.is_included_in(timespan): yield item.item elif item.key > timespan: break def overlapping( self, start: datetime.date | datetime.datetime, end: datetime.date | datetime.datetime, ) -> Iterator[T]: """Return an iterator containing events active during the timespan. The end date is exclusive. """ timespan = Timespan.of(start, end) for item in self._iterable: if item.key.intersects(timespan): yield item.item elif item.key > timespan: break def start_after( self, instant: datetime.datetime | datetime.date, ) -> Iterator[T]: """Return an iterator containing events starting after the specified time.""" instant_value = normalize_datetime(instant) if not instant_value.tzinfo: raise ValueError("Expected tzinfo to be set on normalized datetime") for item in self._iterable: if item.key.start > instant_value: yield item.item def active_after( self, instant: datetime.datetime | datetime.date, ) -> Iterator[T]: """Return an iterator containing events active after the specified time.""" instant_value = normalize_datetime(instant) if not instant_value.tzinfo: raise ValueError("Expected tzinfo to be set on normalized datetime") for item in self._iterable: if item.key.start > instant_value or item.key.end > instant_value: yield item.item def at_instant( self, instant: datetime.date | datetime.datetime, ) -> Iterator[T]: # pylint: disable """Return an iterator containing events starting after the specified time.""" timespan = Timespan.of(instant, instant) for item in self._iterable: if item.key.includes(timespan): yield item.item elif item.key > timespan: break def on_date(self, day: datetime.date) -> Iterator[T]: # pylint: disable """Return an iterator containing all events active on the specified day.""" return self.overlapping(day, day + datetime.timedelta(days=1)) def today(self) -> Iterator[T]: """Return an iterator containing all events active on the specified day.""" return self.on_date(datetime.date.today()) def now(self, tz: datetime.tzinfo | None = None) -> Iterator[T]: """Return an iterator containing all events active on the specified day.""" return self.at_instant(datetime.datetime.now(tz=tz)) def as_rrule( rrule: Recur | None, rdate: list[datetime.datetime | datetime.date], exdate: list[datetime.datetime | datetime.date], start: datetime.datetime | datetime.date | None, ) -> Iterable[datetime.datetime | datetime.date] | None: """Return an iterable containing the occurrences of a recurring event. A recurring event is typically evaluated specially on the timeline. The data model has a single event, but the timeline evaluates the recurrence to expand and copy the event to multiple places on the timeline. This is only valid for events where `recurring` is True. """ if not rrule and not rdate: return None if not start: raise CalendarParseError("Event must have a start date to be recurring") return RulesetIterable( start, [rrule.as_rrule(start)] if rrule else [], rdate, exdate, ) allenporter-ical-fe8800b/ical/journal.py000066400000000000000000000142201510550726100203220ustar00rootroot00000000000000"""A grouping of component properties that describe a journal entry.""" # pylint: disable=unnecessary-lambda from __future__ import annotations import datetime import enum import logging from collections.abc import Iterable from typing import Annotated, Any, Optional, Union from pydantic import BeforeValidator, Field, field_serializer, model_validator from ical.types.data_types import serialize_field from .component import ComponentModel, validate_until_dtstart, validate_recurrence_dates from .parsing.property import ParsedProperty from .types import ( CalAddress, Classification, Recur, RecurrenceId, RequestStatus, Uri, RelatedTo, ) from .util import ( dtstamp_factory, normalize_datetime, parse_date_and_datetime, parse_date_and_datetime_list, uid_factory, local_timezone, ) from .iter import RulesetIterable, as_rrule from .timespan import Timespan _LOGGER = logging.getLogger(__name__) __all__ = ["Journal", "JournalStatus"] _ONE_HOUR = datetime.timedelta(hours=1) _ONE_DAY = datetime.timedelta(days=1) class JournalStatus(str, enum.Enum): """Status or confirmation of the journal entry.""" DRAFT = "DRAFT" FINAL = "FINAL" CANCELLED = "CANCELLED" class Journal(ComponentModel): """A single journal entry on a calendar. A journal entry consists of one or more text notes associated with a specific calendar date. Can either be for a specific day, or with a start time and duration/end time. The dtstamp and uid functions have factory methods invoked with a lambda to facilitate mocking in unit tests. """ dtstamp: Annotated[ Union[datetime.date, datetime.datetime], BeforeValidator(parse_date_and_datetime), ] = Field(default_factory=lambda: dtstamp_factory()) uid: str = Field(default_factory=lambda: uid_factory()) attendees: list[CalAddress] = Field(alias="attendee", default_factory=list) categories: list[str] = Field(default_factory=list) classification: Optional[Classification] = Field(alias="class", default=None) comment: list[str] = Field(default_factory=list) contacts: list[str] = Field(alias="contact", default_factory=list) created: Optional[datetime.datetime] = None description: Optional[str] = None # Has an alias of 'start' dtstart: Annotated[ Union[datetime.date, datetime.datetime, None], BeforeValidator(parse_date_and_datetime), ] = Field(default=None) exdate: Annotated[ list[Union[datetime.date, datetime.datetime]], BeforeValidator(parse_date_and_datetime_list), ] = Field(default_factory=list) last_modified: Optional[datetime.datetime] = Field( alias="last-modified", default=None ) organizer: Optional[CalAddress] = None recurrence_id: Optional[RecurrenceId] = Field(default=None, alias="recurrence-id") related_to: list[RelatedTo] = Field(alias="related-to", default_factory=list) """Used to represent a relationship or reference between events.""" related: list[str] = Field(default_factory=list) rrule: Optional[Recur] = None rdate: Annotated[ list[Union[datetime.date, datetime.datetime]], BeforeValidator(parse_date_and_datetime_list), ] = Field(default_factory=list) request_status: Optional[RequestStatus] = Field( default=None, alias="request-status", ) sequence: Optional[int] = None status: Optional[JournalStatus] = None summary: Optional[str] = None url: Optional[Uri] = None # Unknown or unsupported properties extras: list[ParsedProperty] = Field(default_factory=list) def __init__(self, **data: Any) -> None: """Initialize Event.""" if "start" in data: data["dtstart"] = data.pop("start") super().__init__(**data) @property def start(self) -> datetime.datetime | datetime.date: """Return the start time for the event.""" assert self.dtstart is not None return self.dtstart @property def start_datetime(self) -> datetime.datetime: """Return the events start as a datetime.""" return normalize_datetime(self.start).astimezone(tz=datetime.timezone.utc) @property def computed_duration(self) -> datetime.timedelta: """Return the event duration.""" if isinstance(self.dtstart, datetime.datetime): return _ONE_HOUR return _ONE_DAY @property def timespan(self) -> Timespan: """Return a timespan representing the item start and due date.""" return self.timespan_of(local_timezone()) def timespan_of(self, tzinfo: datetime.tzinfo) -> Timespan: """Return a timespan representing the item start and due date.""" assert self.dtstart is not None dtstart = normalize_datetime(self.dtstart, tzinfo) or datetime.datetime.now( tz=tzinfo ) return Timespan.of(dtstart, dtstart + self.computed_duration, tzinfo) @property def recurring(self) -> bool: """Return true if this Todo is recurring. A recurring event is typically evaluated specially on the list. The data model has a single todo, but the timeline evaluates the recurrence to expand and copy the event to multiple places on the timeline using `as_rrule`. """ if self.rrule or self.rdate: return True return False def as_rrule(self) -> Iterable[datetime.datetime | datetime.date] | None: """Return an iterable containing the occurrences of a recurring todo. A recurring todo is typically evaluated specially on the todo list. The data model has a single todo item, but the timeline evaluates the recurrence to expand and copy the item to multiple places on the timeline. This is only valid for events where `recurring` is True. """ return as_rrule(self.rrule, self.rdate, self.exdate, self.dtstart) _validate_until_dtstart = model_validator(mode="after")(validate_until_dtstart) _validate_recurrence_dates = model_validator(mode="after")( validate_recurrence_dates ) serialize_fields = field_serializer("*")(serialize_field) # type: ignore[pydantic-field] allenporter-ical-fe8800b/ical/list.py000066400000000000000000000040301510550726100176210ustar00rootroot00000000000000"""A List is a set of objects on a calendar. A List is used to iterate over all objects, including expanded recurring objects. A List is similar to a Timeline, except it does not repeat recurring objects on the list and they are only shown once. A list does not repeat forever. """ import datetime from collections.abc import Generator import logging from .todo import Todo from .recur_adapter import items_by_uid, merge_and_expand_items from .util import local_timezone # Not part of the public API __all__: list[str] = [] _LOGGER = logging.getLogger(__name__) def _pick_todo(todos: list[Todo], dtstart: datetime.datetime) -> Todo: """Pick a todo to return in a list from a list of recurring todos. The items passed in must all be for the same original todo (either a single todo or instance of a recurring todo including any edits). An edited instance of a recurring todo has a recurrence-id that is different from the original todo. This function will return the next todo that is incomplete and has the latest due date. """ # For a recurring todo, the dtstart is after the last due date. Therefore # we can sort items by dtstart and pick the last one that hasn't happened root_iter = merge_and_expand_items(todos, dtstart.tzinfo or local_timezone()) it = iter(root_iter) last = next(it) while cur := next(it, None): if cur.item.start_datetime is None or cur.item.start_datetime > dtstart: break last = cur return last.item def todo_list_view( todos: list[Todo], dtstart: datetime.datetime | None = None, ) -> Generator[Todo, None, None]: """Create a list view for todos on a calendar, including recurrence. The dtstart value is used to determine the current time for the list and for deciding which instance of a recurring todo to return. """ if dtstart is None: dtstart = datetime.datetime.now(tz=local_timezone()) todos_by_uid = items_by_uid(todos) for todos in todos_by_uid.values(): yield _pick_todo(todos, dtstart) allenporter-ical-fe8800b/ical/parsing/000077500000000000000000000000001510550726100177425ustar00rootroot00000000000000allenporter-ical-fe8800b/ical/parsing/__init__.py000066400000000000000000000000551510550726100220530ustar00rootroot00000000000000"""Library for parsing rfc5545 ics files.""" allenporter-ical-fe8800b/ical/parsing/component.py000066400000000000000000000075531510550726100223300ustar00rootroot00000000000000"""Library for handling rfc5545 components. An iCalendar object consists of one or more components, that may have properties or sub-components. An example of a component might be the calendar itself, an event, a to-do, a journal entry, timezone info, etc. Components created here have no semantic meaning, but hold all the data needed to interpret based on the type (e.g. by a pydantic model) """ # mypy: allow-any-generics from __future__ import annotations import re import textwrap from dataclasses import dataclass, field from collections.abc import Generator from .const import ( ATTR_BEGIN, ATTR_END, FOLD, FOLD_INDENT, FOLD_LEN, ATTR_BEGIN_LOWER, ATTR_END_LOWER, ) from .property import ParsedProperty, parse_contentlines FOLD_RE = re.compile(FOLD, flags=re.MULTILINE) LINES_RE = re.compile(r"\r?\n") @dataclass class ParsedComponent: """An rfc5545 component.""" name: str properties: list[ParsedProperty] = field(default_factory=list) components: list[ParsedComponent] = field(default_factory=list) def as_dict(self) -> dict[str, str | list[ParsedProperty | dict]]: """Convert the component into a pydantic parseable dictionary.""" result: dict[str, list[ParsedProperty | dict]] = {} for prop in self.properties: result.setdefault(prop.name, []) result[prop.name].append(prop) for component in self.components: result.setdefault(component.name, []) result[component.name].append(component.as_dict()) return { "name": self.name, **result, } def ics(self) -> str: """Encode a component as rfc5545 text.""" contentlines = [] name = self.name.upper() contentlines.append(f"{ATTR_BEGIN}:{name}") for prop in self.properties: contentlines.extend(_fold(prop.ics())) contentlines.extend([component.ics() for component in self.components]) contentlines.append(f"{ATTR_END}:{name}") return "\n".join(contentlines) def _fold(contentline: str) -> list[str]: return textwrap.wrap( contentline, width=FOLD_LEN, subsequent_indent=FOLD_INDENT, drop_whitespace=False, replace_whitespace=False, expand_tabs=False, break_on_hyphens=False, ) def parse_content(content: str) -> list[ParsedComponent]: """Parse content into raw properties. This includes all necessary unfolding of long lines into full properties. This is fairly straight forward in that it walks through each line and uses a stack to associate properties with the current object. This does the absolute minimum possible parsing into a dictionary of objects to get the right structure. All the more detailed parsing of the objects is handled by pydantic, elsewhere. """ lines = unfolded_lines(content) properties = parse_contentlines(lines) stack: list[ParsedComponent] = [ParsedComponent(name="stream")] for prop in properties: if prop.name == ATTR_BEGIN_LOWER: stack.append(ParsedComponent(name=prop.value.lower())) elif prop.name == ATTR_END_LOWER: component = stack.pop() if prop.value.lower() != component.name: raise ValueError( f"Unexpected '{prop}', expected {ATTR_END}:{component.name}" ) stack[-1].components.append(component) else: stack[-1].properties.append(prop) return stack[0].components def encode_content(components: list[ParsedComponent]) -> str: """Encode a set of parsed properties into content.""" return "\n".join([component.ics() for component in components]) def unfolded_lines(content: str) -> Generator[str, None, None]: """Read content and unfold lines.""" content = FOLD_RE.sub("", content) yield from LINES_RE.split(content) allenporter-ical-fe8800b/ical/parsing/const.py000066400000000000000000000003561510550726100214460ustar00rootroot00000000000000"""Constants for ical parsing library.""" # Related to rfc5545 text parsing FOLD = r"\r?\n[ |\t]" FOLD_LEN = 75 FOLD_INDENT = " " WSP = [" ", "\t"] ATTR_BEGIN = "BEGIN" ATTR_END = "END" ATTR_BEGIN_LOWER = "begin" ATTR_END_LOWER = "end" allenporter-ical-fe8800b/ical/parsing/emoji.py000066400000000000000000010626671510550726100214410ustar00rootroot00000000000000"""This file is automatically generated by script/update_emoji.py. Do not edit.""" EMOJI = [ "\U0001f947", # 🥇 "\U0001f948", # 🥈 "\U0001f949", # 🥉 "\U0001f18e", # 🆎 "\U0001f3e7", # 🏧 "\U0001f170\U0000fe0f", # 🅰️ "\U0001f170", # 🅰 "\U0001f1e6\U0001f1eb", # 🇦🇫 "\U0001f1e6\U0001f1f1", # 🇦🇱 "\U0001f1e9\U0001f1ff", # 🇩🇿 "\U0001f1e6\U0001f1f8", # 🇦🇸 "\U0001f1e6\U0001f1e9", # 🇦🇩 "\U0001f1e6\U0001f1f4", # 🇦🇴 "\U0001f1e6\U0001f1ee", # 🇦🇮 "\U0001f1e6\U0001f1f6", # 🇦🇶 "\U0001f1e6\U0001f1ec", # 🇦🇬 "\U00002652", # ♒ "\U0001f1e6\U0001f1f7", # 🇦🇷 "\U00002648", # ♈ "\U0001f1e6\U0001f1f2", # 🇦🇲 "\U0001f1e6\U0001f1fc", # 🇦🇼 "\U0001f1e6\U0001f1e8", # 🇦🇨 "\U0001f1e6\U0001f1fa", # 🇦🇺 "\U0001f1e6\U0001f1f9", # 🇦🇹 "\U0001f1e6\U0001f1ff", # 🇦🇿 "\U0001f519", # 🔙 "\U0001f171\U0000fe0f", # 🅱️ "\U0001f171", # 🅱 "\U0001f1e7\U0001f1f8", # 🇧🇸 "\U0001f1e7\U0001f1ed", # 🇧🇭 "\U0001f1e7\U0001f1e9", # 🇧🇩 "\U0001f1e7\U0001f1e7", # 🇧🇧 "\U0001f1e7\U0001f1fe", # 🇧🇾 "\U0001f1e7\U0001f1ea", # 🇧🇪 "\U0001f1e7\U0001f1ff", # 🇧🇿 "\U0001f1e7\U0001f1ef", # 🇧🇯 "\U0001f1e7\U0001f1f2", # 🇧🇲 "\U0001f1e7\U0001f1f9", # 🇧🇹 "\U0001f1e7\U0001f1f4", # 🇧🇴 "\U0001f1e7\U0001f1e6", # 🇧🇦 "\U0001f1e7\U0001f1fc", # 🇧🇼 "\U0001f1e7\U0001f1fb", # 🇧🇻 "\U0001f1e7\U0001f1f7", # 🇧🇷 "\U0001f1ee\U0001f1f4", # 🇮🇴 "\U0001f1fb\U0001f1ec", # 🇻🇬 "\U0001f1e7\U0001f1f3", # 🇧🇳 "\U0001f1e7\U0001f1ec", # 🇧🇬 "\U0001f1e7\U0001f1eb", # 🇧🇫 "\U0001f1e7\U0001f1ee", # 🇧🇮 "\U0001f191", # 🆑 "\U0001f192", # 🆒 "\U0001f1f0\U0001f1ed", # 🇰🇭 "\U0001f1e8\U0001f1f2", # 🇨🇲 "\U0001f1e8\U0001f1e6", # 🇨🇦 "\U0001f1ee\U0001f1e8", # 🇮🇨 "\U0000264b", # ♋ "\U0001f1e8\U0001f1fb", # 🇨🇻 "\U00002651", # ♑ "\U0001f1e7\U0001f1f6", # 🇧🇶 "\U0001f1f0\U0001f1fe", # 🇰🇾 "\U0001f1e8\U0001f1eb", # 🇨🇫 "\U0001f1ea\U0001f1e6", # 🇪🇦 "\U0001f1f9\U0001f1e9", # 🇹🇩 "\U0001f1e8\U0001f1f1", # 🇨🇱 "\U0001f1e8\U0001f1f3", # 🇨🇳 "\U0001f1e8\U0001f1fd", # 🇨🇽 "\U0001f384", # 🎄 "\U0001f1e8\U0001f1f5", # 🇨🇵 "\U0001f1e8\U0001f1e8", # 🇨🇨 "\U0001f1e8\U0001f1f4", # 🇨🇴 "\U0001f1f0\U0001f1f2", # 🇰🇲 "\U0001f1e8\U0001f1ec", # 🇨🇬 "\U0001f1e8\U0001f1e9", # 🇨🇩 "\U0001f1e8\U0001f1f0", # 🇨🇰 "\U0001f1e8\U0001f1f7", # 🇨🇷 "\U0001f1ed\U0001f1f7", # 🇭🇷 "\U0001f1e8\U0001f1fa", # 🇨🇺 "\U0001f1e8\U0001f1fc", # 🇨🇼 "\U0001f1e8\U0001f1fe", # 🇨🇾 "\U0001f1e8\U0001f1ff", # 🇨🇿 "\U0001f1e8\U0001f1ee", # 🇨🇮 "\U0001f1e9\U0001f1f0", # 🇩🇰 "\U0001f1e9\U0001f1ec", # 🇩🇬 "\U0001f1e9\U0001f1ef", # 🇩🇯 "\U0001f1e9\U0001f1f2", # 🇩🇲 "\U0001f1e9\U0001f1f4", # 🇩🇴 "\U0001f51a", # 🔚 "\U0001f1ea\U0001f1e8", # 🇪🇨 "\U0001f1ea\U0001f1ec", # 🇪🇬 "\U0001f1f8\U0001f1fb", # 🇸🇻 "\U0001f3f4\U000e0067\U000e0062\U000e0065\U000e006e\U000e0067\U000e007f", # 🏴󠁧󠁢󠁥󠁮󠁧󠁿 "\U0001f1ec\U0001f1f6", # 🇬🇶 "\U0001f1ea\U0001f1f7", # 🇪🇷 "\U0001f1ea\U0001f1ea", # 🇪🇪 "\U0001f1f8\U0001f1ff", # 🇸🇿 "\U0001f1ea\U0001f1f9", # 🇪🇹 "\U0001f1ea\U0001f1fa", # 🇪🇺 "\U0001f193", # 🆓 "\U0001f1eb\U0001f1f0", # 🇫🇰 "\U0001f1eb\U0001f1f4", # 🇫🇴 "\U0001f1eb\U0001f1ef", # 🇫🇯 "\U0001f1eb\U0001f1ee", # 🇫🇮 "\U0001f1eb\U0001f1f7", # 🇫🇷 "\U0001f1ec\U0001f1eb", # 🇬🇫 "\U0001f1f5\U0001f1eb", # 🇵🇫 "\U0001f1f9\U0001f1eb", # 🇹🇫 "\U0001f1ec\U0001f1e6", # 🇬🇦 "\U0001f1ec\U0001f1f2", # 🇬🇲 "\U0000264a", # ♊ "\U0001f1ec\U0001f1ea", # 🇬🇪 "\U0001f1e9\U0001f1ea", # 🇩🇪 "\U0001f1ec\U0001f1ed", # 🇬🇭 "\U0001f1ec\U0001f1ee", # 🇬🇮 "\U0001f1ec\U0001f1f7", # 🇬🇷 "\U0001f1ec\U0001f1f1", # 🇬🇱 "\U0001f1ec\U0001f1e9", # 🇬🇩 "\U0001f1ec\U0001f1f5", # 🇬🇵 "\U0001f1ec\U0001f1fa", # 🇬🇺 "\U0001f1ec\U0001f1f9", # 🇬🇹 "\U0001f1ec\U0001f1ec", # 🇬🇬 "\U0001f1ec\U0001f1f3", # 🇬🇳 "\U0001f1ec\U0001f1fc", # 🇬🇼 "\U0001f1ec\U0001f1fe", # 🇬🇾 "\U0001f1ed\U0001f1f9", # 🇭🇹 "\U0001f1ed\U0001f1f2", # 🇭🇲 "\U0001f1ed\U0001f1f3", # 🇭🇳 "\U0001f1ed\U0001f1f0", # 🇭🇰 "\U0001f1ed\U0001f1fa", # 🇭🇺 "\U0001f194", # 🆔 "\U0001f1ee\U0001f1f8", # 🇮🇸 "\U0001f1ee\U0001f1f3", # 🇮🇳 "\U0001f1ee\U0001f1e9", # 🇮🇩 "\U0001f1ee\U0001f1f7", # 🇮🇷 "\U0001f1ee\U0001f1f6", # 🇮🇶 "\U0001f1ee\U0001f1ea", # 🇮🇪 "\U0001f1ee\U0001f1f2", # 🇮🇲 "\U0001f1ee\U0001f1f1", # 🇮🇱 "\U0001f1ee\U0001f1f9", # 🇮🇹 "\U0001f1ef\U0001f1f2", # 🇯🇲 "\U0001f1ef\U0001f1f5", # 🇯🇵 "\U0001f251", # 🉑 "\U0001f238", # 🈸 "\U0001f250", # 🉐 "\U0001f3ef", # 🏯 "\U00003297\U0000fe0f", # ㊗️ "\U00003297", # ㊗ "\U0001f239", # 🈹 "\U0001f38e", # 🎎 "\U0001f21a", # 🈚 "\U0001f201", # 🈁 "\U0001f237\U0000fe0f", # 🈷️ "\U0001f237", # 🈷 "\U0001f235", # 🈵 "\U0001f236", # 🈶 "\U0001f23a", # 🈺 "\U0001f234", # 🈴 "\U0001f3e3", # 🏣 "\U0001f232", # 🈲 "\U0001f22f", # 🈯 "\U00003299\U0000fe0f", # ㊙️ "\U00003299", # ㊙ "\U0001f202\U0000fe0f", # 🈂️ "\U0001f202", # 🈂 "\U0001f530", # 🔰 "\U0001f233", # 🈳 "\U0001f1ef\U0001f1ea", # 🇯🇪 "\U0001f1ef\U0001f1f4", # 🇯🇴 "\U0001f1f0\U0001f1ff", # 🇰🇿 "\U0001f1f0\U0001f1ea", # 🇰🇪 "\U0001f1f0\U0001f1ee", # 🇰🇮 "\U0001f1fd\U0001f1f0", # 🇽🇰 "\U0001f1f0\U0001f1fc", # 🇰🇼 "\U0001f1f0\U0001f1ec", # 🇰🇬 "\U0001f1f1\U0001f1e6", # 🇱🇦 "\U0001f1f1\U0001f1fb", # 🇱🇻 "\U0001f1f1\U0001f1e7", # 🇱🇧 "\U0000264c", # ♌ "\U0001f1f1\U0001f1f8", # 🇱🇸 "\U0001f1f1\U0001f1f7", # 🇱🇷 "\U0000264e", # ♎ "\U0001f1f1\U0001f1fe", # 🇱🇾 "\U0001f1f1\U0001f1ee", # 🇱🇮 "\U0001f1f1\U0001f1f9", # 🇱🇹 "\U0001f1f1\U0001f1fa", # 🇱🇺 "\U0001f1f2\U0001f1f4", # 🇲🇴 "\U0001f1f2\U0001f1ec", # 🇲🇬 "\U0001f1f2\U0001f1fc", # 🇲🇼 "\U0001f1f2\U0001f1fe", # 🇲🇾 "\U0001f1f2\U0001f1fb", # 🇲🇻 "\U0001f1f2\U0001f1f1", # 🇲🇱 "\U0001f1f2\U0001f1f9", # 🇲🇹 "\U0001f1f2\U0001f1ed", # 🇲🇭 "\U0001f1f2\U0001f1f6", # 🇲🇶 "\U0001f1f2\U0001f1f7", # 🇲🇷 "\U0001f1f2\U0001f1fa", # 🇲🇺 "\U0001f1fe\U0001f1f9", # 🇾🇹 "\U0001f1f2\U0001f1fd", # 🇲🇽 "\U0001f1eb\U0001f1f2", # 🇫🇲 "\U0001f1f2\U0001f1e9", # 🇲🇩 "\U0001f1f2\U0001f1e8", # 🇲🇨 "\U0001f1f2\U0001f1f3", # 🇲🇳 "\U0001f1f2\U0001f1ea", # 🇲🇪 "\U0001f1f2\U0001f1f8", # 🇲🇸 "\U0001f1f2\U0001f1e6", # 🇲🇦 "\U0001f1f2\U0001f1ff", # 🇲🇿 "\U0001f936", # 🤶 "\U0001f936\U0001f3ff", # 🤶🏿 "\U0001f936\U0001f3fb", # 🤶🏻 "\U0001f936\U0001f3fe", # 🤶🏾 "\U0001f936\U0001f3fc", # 🤶🏼 "\U0001f936\U0001f3fd", # 🤶🏽 "\U0001f9d1\U0000200d\U0001f384", # 🧑‍🎄 "\U0001f9d1\U0001f3ff\U0000200d\U0001f384", # 🧑🏿‍🎄 "\U0001f9d1\U0001f3fb\U0000200d\U0001f384", # 🧑🏻‍🎄 "\U0001f9d1\U0001f3fe\U0000200d\U0001f384", # 🧑🏾‍🎄 "\U0001f9d1\U0001f3fc\U0000200d\U0001f384", # 🧑🏼‍🎄 "\U0001f9d1\U0001f3fd\U0000200d\U0001f384", # 🧑🏽‍🎄 "\U0001f1f2\U0001f1f2", # 🇲🇲 "\U0001f195", # 🆕 "\U0001f196", # 🆖 "\U0001f1f3\U0001f1e6", # 🇳🇦 "\U0001f1f3\U0001f1f7", # 🇳🇷 "\U0001f1f3\U0001f1f5", # 🇳🇵 "\U0001f1f3\U0001f1f1", # 🇳🇱 "\U0001f1f3\U0001f1e8", # 🇳🇨 "\U0001f1f3\U0001f1ff", # 🇳🇿 "\U0001f1f3\U0001f1ee", # 🇳🇮 "\U0001f1f3\U0001f1ea", # 🇳🇪 "\U0001f1f3\U0001f1ec", # 🇳🇬 "\U0001f1f3\U0001f1fa", # 🇳🇺 "\U0001f1f3\U0001f1eb", # 🇳🇫 "\U0001f1f0\U0001f1f5", # 🇰🇵 "\U0001f1f2\U0001f1f0", # 🇲🇰 "\U0001f1f2\U0001f1f5", # 🇲🇵 "\U0001f1f3\U0001f1f4", # 🇳🇴 "\U0001f197", # 🆗 "\U0001f44c", # 👌 "\U0001f44c\U0001f3ff", # 👌🏿 "\U0001f44c\U0001f3fb", # 👌🏻 "\U0001f44c\U0001f3fe", # 👌🏾 "\U0001f44c\U0001f3fc", # 👌🏼 "\U0001f44c\U0001f3fd", # 👌🏽 "\U0001f51b", # 🔛 "\U0001f17e\U0000fe0f", # 🅾️ "\U0001f17e", # 🅾 "\U0001f1f4\U0001f1f2", # 🇴🇲 "\U000026ce", # ⛎ "\U0001f17f\U0000fe0f", # 🅿️ "\U0001f17f", # 🅿 "\U0001f1f5\U0001f1f0", # 🇵🇰 "\U0001f1f5\U0001f1fc", # 🇵🇼 "\U0001f1f5\U0001f1f8", # 🇵🇸 "\U0001f1f5\U0001f1e6", # 🇵🇦 "\U0001f1f5\U0001f1ec", # 🇵🇬 "\U0001f1f5\U0001f1fe", # 🇵🇾 "\U0001f1f5\U0001f1ea", # 🇵🇪 "\U0001f1f5\U0001f1ed", # 🇵🇭 "\U00002653", # ♓ "\U0001f1f5\U0001f1f3", # 🇵🇳 "\U0001f1f5\U0001f1f1", # 🇵🇱 "\U0001f1f5\U0001f1f9", # 🇵🇹 "\U0001f1f5\U0001f1f7", # 🇵🇷 "\U0001f1f6\U0001f1e6", # 🇶🇦 "\U0001f1f7\U0001f1f4", # 🇷🇴 "\U0001f1f7\U0001f1fa", # 🇷🇺 "\U0001f1f7\U0001f1fc", # 🇷🇼 "\U0001f1f7\U0001f1ea", # 🇷🇪 "\U0001f51c", # 🔜 "\U0001f198", # 🆘 "\U00002650", # ♐ "\U0001f1fc\U0001f1f8", # 🇼🇸 "\U0001f1f8\U0001f1f2", # 🇸🇲 "\U0001f385", # 🎅 "\U0001f385\U0001f3ff", # 🎅🏿 "\U0001f385\U0001f3fb", # 🎅🏻 "\U0001f385\U0001f3fe", # 🎅🏾 "\U0001f385\U0001f3fc", # 🎅🏼 "\U0001f385\U0001f3fd", # 🎅🏽 "\U0001f1e8\U0001f1f6", # 🇨🇶 "\U0001f1f8\U0001f1e6", # 🇸🇦 "\U0000264f", # ♏ "\U0001f3f4\U000e0067\U000e0062\U000e0073\U000e0063\U000e0074\U000e007f", # 🏴󠁧󠁢󠁳󠁣󠁴󠁿 "\U0001f1f8\U0001f1f3", # 🇸🇳 "\U0001f1f7\U0001f1f8", # 🇷🇸 "\U0001f1f8\U0001f1e8", # 🇸🇨 "\U0001f1f8\U0001f1f1", # 🇸🇱 "\U0001f1f8\U0001f1ec", # 🇸🇬 "\U0001f1f8\U0001f1fd", # 🇸🇽 "\U0001f1f8\U0001f1f0", # 🇸🇰 "\U0001f1f8\U0001f1ee", # 🇸🇮 "\U0001f1f8\U0001f1e7", # 🇸🇧 "\U0001f1f8\U0001f1f4", # 🇸🇴 "\U0001f1ff\U0001f1e6", # 🇿🇦 "\U0001f1ec\U0001f1f8", # 🇬🇸 "\U0001f1f0\U0001f1f7", # 🇰🇷 "\U0001f1f8\U0001f1f8", # 🇸🇸 "\U0001f1ea\U0001f1f8", # 🇪🇸 "\U0001f1f1\U0001f1f0", # 🇱🇰 "\U0001f1e7\U0001f1f1", # 🇧🇱 "\U0001f1f8\U0001f1ed", # 🇸🇭 "\U0001f1f0\U0001f1f3", # 🇰🇳 "\U0001f1f1\U0001f1e8", # 🇱🇨 "\U0001f1f2\U0001f1eb", # 🇲🇫 "\U0001f1f5\U0001f1f2", # 🇵🇲 "\U0001f1fb\U0001f1e8", # 🇻🇨 "\U0001f5fd", # 🗽 "\U0001f1f8\U0001f1e9", # 🇸🇩 "\U0001f1f8\U0001f1f7", # 🇸🇷 "\U0001f1f8\U0001f1ef", # 🇸🇯 "\U0001f1f8\U0001f1ea", # 🇸🇪 "\U0001f1e8\U0001f1ed", # 🇨🇭 "\U0001f1f8\U0001f1fe", # 🇸🇾 "\U0001f1f8\U0001f1f9", # 🇸🇹 "\U0001f996", # 🦖 "\U0001f51d", # 🔝 "\U0001f1f9\U0001f1fc", # 🇹🇼 "\U0001f1f9\U0001f1ef", # 🇹🇯 "\U0001f1f9\U0001f1ff", # 🇹🇿 "\U00002649", # ♉ "\U0001f1f9\U0001f1ed", # 🇹🇭 "\U0001f1f9\U0001f1f1", # 🇹🇱 "\U0001f1f9\U0001f1ec", # 🇹🇬 "\U0001f1f9\U0001f1f0", # 🇹🇰 "\U0001f5fc", # 🗼 "\U0001f1f9\U0001f1f4", # 🇹🇴 "\U0001f1f9\U0001f1f9", # 🇹🇹 "\U0001f1f9\U0001f1e6", # 🇹🇦 "\U0001f1f9\U0001f1f3", # 🇹🇳 "\U0001f1f9\U0001f1f2", # 🇹🇲 "\U0001f1f9\U0001f1e8", # 🇹🇨 "\U0001f1f9\U0001f1fb", # 🇹🇻 "\U0001f1f9\U0001f1f7", # 🇹🇷 "\U0001f1fa\U0001f1f2", # 🇺🇲 "\U0001f1fb\U0001f1ee", # 🇻🇮 "\U0001f199", # 🆙 "\U0001f1fa\U0001f1ec", # 🇺🇬 "\U0001f1fa\U0001f1e6", # 🇺🇦 "\U0001f1e6\U0001f1ea", # 🇦🇪 "\U0001f1ec\U0001f1e7", # 🇬🇧 "\U0001f1fa\U0001f1f3", # 🇺🇳 "\U0001f1fa\U0001f1f8", # 🇺🇸 "\U0001f1fa\U0001f1fe", # 🇺🇾 "\U0001f1fa\U0001f1ff", # 🇺🇿 "\U0001f19a", # 🆚 "\U0001f1fb\U0001f1fa", # 🇻🇺 "\U0001f1fb\U0001f1e6", # 🇻🇦 "\U0001f1fb\U0001f1ea", # 🇻🇪 "\U0001f1fb\U0001f1f3", # 🇻🇳 "\U0000264d", # ♍ "\U0001f3f4\U000e0067\U000e0062\U000e0077\U000e006c\U000e0073\U000e007f", # 🏴󠁧󠁢󠁷󠁬󠁳󠁿 "\U0001f1fc\U0001f1eb", # 🇼🇫 "\U0001f1ea\U0001f1ed", # 🇪🇭 "\U0001f1fe\U0001f1ea", # 🇾🇪 "\U0001f4a4", # 💤 "\U0001f1ff\U0001f1f2", # 🇿🇲 "\U0001f1ff\U0001f1fc", # 🇿🇼 "\U0001f9ee", # 🧮 "\U0001fa97", # 🪗 "\U0001fa79", # 🩹 "\U0001f39f\U0000fe0f", # 🎟️ "\U0001f39f", # 🎟 "\U0001f6a1", # 🚡 "\U00002708\U0000fe0f", # ✈️ "\U00002708", # ✈ "\U0001f6ec", # 🛬 "\U0001f6eb", # 🛫 "\U000023f0", # ⏰ "\U00002697\U0000fe0f", # ⚗️ "\U00002697", # ⚗ "\U0001f47d", # 👽 "\U0001f47e", # 👾 "\U0001f691", # 🚑 "\U0001f3c8", # 🏈 "\U0001f3fa", # 🏺 "\U0001fac0", # 🫀 "\U00002693", # ⚓ "\U0001f4a2", # 💢 "\U0001f620", # 😠 "\U0001f47f", # 👿 "\U0001f627", # 😧 "\U0001f41c", # 🐜 "\U0001f4f6", # 📶 "\U0001f630", # 😰 "\U0001f69b", # 🚛 "\U0001f9d1\U0000200d\U0001f3a8", # 🧑‍🎨 "\U0001f9d1\U0001f3ff\U0000200d\U0001f3a8", # 🧑🏿‍🎨 "\U0001f9d1\U0001f3fb\U0000200d\U0001f3a8", # 🧑🏻‍🎨 "\U0001f9d1\U0001f3fe\U0000200d\U0001f3a8", # 🧑🏾‍🎨 "\U0001f9d1\U0001f3fc\U0000200d\U0001f3a8", # 🧑🏼‍🎨 "\U0001f9d1\U0001f3fd\U0000200d\U0001f3a8", # 🧑🏽‍🎨 "\U0001f3a8", # 🎨 "\U0001f632", # 😲 "\U0001f9d1\U0000200d\U0001f680", # 🧑‍🚀 "\U0001f9d1\U0001f3ff\U0000200d\U0001f680", # 🧑🏿‍🚀 "\U0001f9d1\U0001f3fb\U0000200d\U0001f680", # 🧑🏻‍🚀 "\U0001f9d1\U0001f3fe\U0000200d\U0001f680", # 🧑🏾‍🚀 "\U0001f9d1\U0001f3fc\U0000200d\U0001f680", # 🧑🏼‍🚀 "\U0001f9d1\U0001f3fd\U0000200d\U0001f680", # 🧑🏽‍🚀 "\U0000269b\U0000fe0f", # ⚛️ "\U0000269b", # ⚛ "\U0001f6fa", # 🛺 "\U0001f697", # 🚗 "\U0001f951", # 🥑 "\U0001fa93", # 🪓 "\U0001f476", # 👶 "\U0001f47c", # 👼 "\U0001f47c\U0001f3ff", # 👼🏿 "\U0001f47c\U0001f3fb", # 👼🏻 "\U0001f47c\U0001f3fe", # 👼🏾 "\U0001f47c\U0001f3fc", # 👼🏼 "\U0001f47c\U0001f3fd", # 👼🏽 "\U0001f37c", # 🍼 "\U0001f424", # 🐤 "\U0001f476\U0001f3ff", # 👶🏿 "\U0001f476\U0001f3fb", # 👶🏻 "\U0001f476\U0001f3fe", # 👶🏾 "\U0001f476\U0001f3fc", # 👶🏼 "\U0001f476\U0001f3fd", # 👶🏽 "\U0001f6bc", # 🚼 "\U0001f447", # 👇 "\U0001f447\U0001f3ff", # 👇🏿 "\U0001f447\U0001f3fb", # 👇🏻 "\U0001f447\U0001f3fe", # 👇🏾 "\U0001f447\U0001f3fc", # 👇🏼 "\U0001f447\U0001f3fd", # 👇🏽 "\U0001f448", # 👈 "\U0001f448\U0001f3ff", # 👈🏿 "\U0001f448\U0001f3fb", # 👈🏻 "\U0001f448\U0001f3fe", # 👈🏾 "\U0001f448\U0001f3fc", # 👈🏼 "\U0001f448\U0001f3fd", # 👈🏽 "\U0001f449", # 👉 "\U0001f449\U0001f3ff", # 👉🏿 "\U0001f449\U0001f3fb", # 👉🏻 "\U0001f449\U0001f3fe", # 👉🏾 "\U0001f449\U0001f3fc", # 👉🏼 "\U0001f449\U0001f3fd", # 👉🏽 "\U0001f446", # 👆 "\U0001f446\U0001f3ff", # 👆🏿 "\U0001f446\U0001f3fb", # 👆🏻 "\U0001f446\U0001f3fe", # 👆🏾 "\U0001f446\U0001f3fc", # 👆🏼 "\U0001f446\U0001f3fd", # 👆🏽 "\U0001f392", # 🎒 "\U0001f953", # 🥓 "\U0001f9a1", # 🦡 "\U0001f3f8", # 🏸 "\U0001f96f", # 🥯 "\U0001f6c4", # 🛄 "\U0001f956", # 🥖 "\U00002696\U0000fe0f", # ⚖️ "\U00002696", # ⚖ "\U0001f9b2", # 🦲 "\U0001fa70", # 🩰 "\U0001f388", # 🎈 "\U0001f5f3\U0000fe0f", # 🗳️ "\U0001f5f3", # 🗳 "\U0001f34c", # 🍌 "\U0001fa95", # 🪕 "\U0001f3e6", # 🏦 "\U0001f4ca", # 📊 "\U0001f488", # 💈 "\U000026be", # ⚾ "\U0001f9fa", # 🧺 "\U0001f3c0", # 🏀 "\U0001f987", # 🦇 "\U0001f6c1", # 🛁 "\U0001f50b", # 🔋 "\U0001f3d6\U0000fe0f", # 🏖️ "\U0001f3d6", # 🏖 "\U0001f601", # 😁 "\U0001fad8", # 🫘 "\U0001f43b", # 🐻 "\U0001f493", # 💓 "\U0001f9ab", # 🦫 "\U0001f6cf\U0000fe0f", # 🛏️ "\U0001f6cf", # 🛏 "\U0001f37a", # 🍺 "\U0001fab2", # 🪲 "\U0001f514", # 🔔 "\U0001fad1", # 🫑 "\U0001f515", # 🔕 "\U0001f6ce\U0000fe0f", # 🛎️ "\U0001f6ce", # 🛎 "\U0001f371", # 🍱 "\U0001f9c3", # 🧃 "\U0001f6b2", # 🚲 "\U0001f459", # 👙 "\U0001f9e2", # 🧢 "\U00002623\U0000fe0f", # ☣️ "\U00002623", # ☣ "\U0001f426", # 🐦 "\U0001f382", # 🎂 "\U0001f9ac", # 🦬 "\U0001fae6", # 🫦 "\U0001f426\U0000200d\U00002b1b", # 🐦‍⬛ "\U0001f408\U0000200d\U00002b1b", # 🐈‍⬛ "\U000026ab", # ⚫ "\U0001f3f4", # 🏴 "\U0001f5a4", # 🖤 "\U00002b1b", # ⬛ "\U000025fe", # ◾ "\U000025fc\U0000fe0f", # ◼️ "\U000025fc", # ◼ "\U00002712\U0000fe0f", # ✒️ "\U00002712", # ✒ "\U000025aa\U0000fe0f", # ▪️ "\U000025aa", # ▪ "\U0001f532", # 🔲 "\U0001f33c", # 🌼 "\U0001f421", # 🐡 "\U0001f4d8", # 📘 "\U0001f535", # 🔵 "\U0001f499", # 💙 "\U0001f7e6", # 🟦 "\U0001fad0", # 🫐 "\U0001f417", # 🐗 "\U0001f4a3", # 💣 "\U0001f9b4", # 🦴 "\U0001f516", # 🔖 "\U0001f4d1", # 📑 "\U0001f4da", # 📚 "\U0001fa83", # 🪃 "\U0001f37e", # 🍾 "\U0001f490", # 💐 "\U0001f3f9", # 🏹 "\U0001f963", # 🥣 "\U0001f3b3", # 🎳 "\U0001f94a", # 🥊 "\U0001f466", # 👦 "\U0001f466\U0001f3ff", # 👦🏿 "\U0001f466\U0001f3fb", # 👦🏻 "\U0001f466\U0001f3fe", # 👦🏾 "\U0001f466\U0001f3fc", # 👦🏼 "\U0001f466\U0001f3fd", # 👦🏽 "\U0001f9e0", # 🧠 "\U0001f35e", # 🍞 "\U0001f931", # 🤱 "\U0001f931\U0001f3ff", # 🤱🏿 "\U0001f931\U0001f3fb", # 🤱🏻 "\U0001f931\U0001f3fe", # 🤱🏾 "\U0001f931\U0001f3fc", # 🤱🏼 "\U0001f931\U0001f3fd", # 🤱🏽 "\U0001f9f1", # 🧱 "\U0001f309", # 🌉 "\U0001f4bc", # 💼 "\U0001fa72", # 🩲 "\U0001f506", # 🔆 "\U0001f966", # 🥦 "\U000026d3\U0000fe0f\U0000200d\U0001f4a5", # ⛓️‍💥 "\U000026d3\U0000200d\U0001f4a5", # ⛓‍💥 "\U0001f494", # 💔 "\U0001f9f9", # 🧹 "\U0001f7e4", # 🟤 "\U0001f90e", # 🤎 "\U0001f344\U0000200d\U0001f7eb", # 🍄‍🟫 "\U0001f7eb", # 🟫 "\U0001f9cb", # 🧋 "\U0001fae7", # 🫧 "\U0001faa3", # 🪣 "\U0001f41b", # 🐛 "\U0001f3d7\U0000fe0f", # 🏗️ "\U0001f3d7", # 🏗 "\U0001f685", # 🚅 "\U0001f3af", # 🎯 "\U0001f32f", # 🌯 "\U0001f68c", # 🚌 "\U0001f68f", # 🚏 "\U0001f464", # 👤 "\U0001f465", # 👥 "\U0001f9c8", # 🧈 "\U0001f98b", # 🦋 "\U0001f335", # 🌵 "\U0001f4c5", # 📅 "\U0001f919", # 🤙 "\U0001f919\U0001f3ff", # 🤙🏿 "\U0001f919\U0001f3fb", # 🤙🏻 "\U0001f919\U0001f3fe", # 🤙🏾 "\U0001f919\U0001f3fc", # 🤙🏼 "\U0001f919\U0001f3fd", # 🤙🏽 "\U0001f42a", # 🐪 "\U0001f4f7", # 📷 "\U0001f4f8", # 📸 "\U0001f3d5\U0000fe0f", # 🏕️ "\U0001f3d5", # 🏕 "\U0001f56f\U0000fe0f", # 🕯️ "\U0001f56f", # 🕯 "\U0001f36c", # 🍬 "\U0001f96b", # 🥫 "\U0001f6f6", # 🛶 "\U0001f5c3\U0000fe0f", # 🗃️ "\U0001f5c3", # 🗃 "\U0001f4c7", # 📇 "\U0001f5c2\U0000fe0f", # 🗂️ "\U0001f5c2", # 🗂 "\U0001f3a0", # 🎠 "\U0001f38f", # 🎏 "\U0001fa9a", # 🪚 "\U0001f955", # 🥕 "\U0001f3f0", # 🏰 "\U0001f408", # 🐈 "\U0001f431", # 🐱 "\U0001f639", # 😹 "\U0001f63c", # 😼 "\U000026d3\U0000fe0f", # ⛓️ "\U000026d3", # ⛓ "\U0001fa91", # 🪑 "\U0001f4c9", # 📉 "\U0001f4c8", # 📈 "\U0001f4b9", # 💹 "\U00002611\U0000fe0f", # ☑️ "\U00002611", # ☑ "\U00002714\U0000fe0f", # ✔️ "\U00002714", # ✔ "\U00002705", # ✅ "\U0001f9c0", # 🧀 "\U0001f3c1", # 🏁 "\U0001f352", # 🍒 "\U0001f338", # 🌸 "\U0000265f\U0000fe0f", # ♟️ "\U0000265f", # ♟ "\U0001f330", # 🌰 "\U0001f414", # 🐔 "\U0001f9d2", # 🧒 "\U0001f9d2\U0001f3ff", # 🧒🏿 "\U0001f9d2\U0001f3fb", # 🧒🏻 "\U0001f9d2\U0001f3fe", # 🧒🏾 "\U0001f9d2\U0001f3fc", # 🧒🏼 "\U0001f9d2\U0001f3fd", # 🧒🏽 "\U0001f6b8", # 🚸 "\U0001f43f\U0000fe0f", # 🐿️ "\U0001f43f", # 🐿 "\U0001f36b", # 🍫 "\U0001f962", # 🥢 "\U000026ea", # ⛪ "\U0001f6ac", # 🚬 "\U0001f3a6", # 🎦 "\U000024c2\U0000fe0f", # Ⓜ️ "\U000024c2", # Ⓜ "\U0001f3aa", # 🎪 "\U0001f3d9\U0000fe0f", # 🏙️ "\U0001f3d9", # 🏙 "\U0001f306", # 🌆 "\U0001f5dc\U0000fe0f", # 🗜️ "\U0001f5dc", # 🗜 "\U0001f3ac", # 🎬 "\U0001f44f", # 👏 "\U0001f44f\U0001f3ff", # 👏🏿 "\U0001f44f\U0001f3fb", # 👏🏻 "\U0001f44f\U0001f3fe", # 👏🏾 "\U0001f44f\U0001f3fc", # 👏🏼 "\U0001f44f\U0001f3fd", # 👏🏽 "\U0001f3db\U0000fe0f", # 🏛️ "\U0001f3db", # 🏛 "\U0001f37b", # 🍻 "\U0001f942", # 🥂 "\U0001f4cb", # 📋 "\U0001f503", # 🔃 "\U0001f4d5", # 📕 "\U0001f4ea", # 📪 "\U0001f4eb", # 📫 "\U0001f302", # 🌂 "\U00002601\U0000fe0f", # ☁️ "\U00002601", # ☁ "\U0001f329\U0000fe0f", # 🌩️ "\U0001f329", # 🌩 "\U000026c8\U0000fe0f", # ⛈️ "\U000026c8", # ⛈ "\U0001f327\U0000fe0f", # 🌧️ "\U0001f327", # 🌧 "\U0001f328\U0000fe0f", # 🌨️ "\U0001f328", # 🌨 "\U0001f921", # 🤡 "\U00002663\U0000fe0f", # ♣️ "\U00002663", # ♣ "\U0001f45d", # 👝 "\U0001f9e5", # 🧥 "\U0001fab3", # 🪳 "\U0001f378", # 🍸 "\U0001f965", # 🥥 "\U000026b0\U0000fe0f", # ⚰️ "\U000026b0", # ⚰ "\U0001fa99", # 🪙 "\U0001f976", # 🥶 "\U0001f4a5", # 💥 "\U00002604\U0000fe0f", # ☄️ "\U00002604", # ☄ "\U0001f9ed", # 🧭 "\U0001f4bd", # 💽 "\U0001f5b1\U0000fe0f", # 🖱️ "\U0001f5b1", # 🖱 "\U0001f38a", # 🎊 "\U0001f616", # 😖 "\U0001f615", # 😕 "\U0001f6a7", # 🚧 "\U0001f477", # 👷 "\U0001f477\U0001f3ff", # 👷🏿 "\U0001f477\U0001f3fb", # 👷🏻 "\U0001f477\U0001f3fe", # 👷🏾 "\U0001f477\U0001f3fc", # 👷🏼 "\U0001f477\U0001f3fd", # 👷🏽 "\U0001f39b\U0000fe0f", # 🎛️ "\U0001f39b", # 🎛 "\U0001f3ea", # 🏪 "\U0001f9d1\U0000200d\U0001f373", # 🧑‍🍳 "\U0001f9d1\U0001f3ff\U0000200d\U0001f373", # 🧑🏿‍🍳 "\U0001f9d1\U0001f3fb\U0000200d\U0001f373", # 🧑🏻‍🍳 "\U0001f9d1\U0001f3fe\U0000200d\U0001f373", # 🧑🏾‍🍳 "\U0001f9d1\U0001f3fc\U0000200d\U0001f373", # 🧑🏼‍🍳 "\U0001f9d1\U0001f3fd\U0000200d\U0001f373", # 🧑🏽‍🍳 "\U0001f35a", # 🍚 "\U0001f36a", # 🍪 "\U0001f373", # 🍳 "\U000000a9\U0000fe0f", # ©️ "\U000000a9", # © "\U0001fab8", # 🪸 "\U0001f6cb\U0000fe0f", # 🛋️ "\U0001f6cb", # 🛋 "\U0001f504", # 🔄 "\U0001f491", # 💑 "\U0001f491\U0001f3ff", # 💑🏿 "\U0001f491\U0001f3fb", # 💑🏻 "\U0001f468\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468", # 👨‍❤️‍👨 "\U0001f468\U0000200d\U00002764\U0000200d\U0001f468", # 👨‍❤‍👨 "\U0001f468\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3ff", # 👨🏿‍❤️‍👨🏿 "\U0001f468\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f468\U0001f3ff", # 👨🏿‍❤‍👨🏿 "\U0001f468\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fb", # 👨🏿‍❤️‍👨🏻 "\U0001f468\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fb", # 👨🏿‍❤‍👨🏻 "\U0001f468\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fe", # 👨🏿‍❤️‍👨🏾 "\U0001f468\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fe", # 👨🏿‍❤‍👨🏾 "\U0001f468\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fc", # 👨🏿‍❤️‍👨🏼 "\U0001f468\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fc", # 👨🏿‍❤‍👨🏼 "\U0001f468\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fd", # 👨🏿‍❤️‍👨🏽 "\U0001f468\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fd", # 👨🏿‍❤‍👨🏽 "\U0001f468\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fb", # 👨🏻‍❤️‍👨🏻 "\U0001f468\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fb", # 👨🏻‍❤‍👨🏻 "\U0001f468\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3ff", # 👨🏻‍❤️‍👨🏿 "\U0001f468\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f468\U0001f3ff", # 👨🏻‍❤‍👨🏿 "\U0001f468\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fe", # 👨🏻‍❤️‍👨🏾 "\U0001f468\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fe", # 👨🏻‍❤‍👨🏾 "\U0001f468\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fc", # 👨🏻‍❤️‍👨🏼 "\U0001f468\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fc", # 👨🏻‍❤‍👨🏼 "\U0001f468\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fd", # 👨🏻‍❤️‍👨🏽 "\U0001f468\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fd", # 👨🏻‍❤‍👨🏽 "\U0001f468\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fe", # 👨🏾‍❤️‍👨🏾 "\U0001f468\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fe", # 👨🏾‍❤‍👨🏾 "\U0001f468\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3ff", # 👨🏾‍❤️‍👨🏿 "\U0001f468\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f468\U0001f3ff", # 👨🏾‍❤‍👨🏿 "\U0001f468\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fb", # 👨🏾‍❤️‍👨🏻 "\U0001f468\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fb", # 👨🏾‍❤‍👨🏻 "\U0001f468\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fc", # 👨🏾‍❤️‍👨🏼 "\U0001f468\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fc", # 👨🏾‍❤‍👨🏼 "\U0001f468\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fd", # 👨🏾‍❤️‍👨🏽 "\U0001f468\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fd", # 👨🏾‍❤‍👨🏽 "\U0001f468\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fc", # 👨🏼‍❤️‍👨🏼 "\U0001f468\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fc", # 👨🏼‍❤‍👨🏼 "\U0001f468\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3ff", # 👨🏼‍❤️‍👨🏿 "\U0001f468\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f468\U0001f3ff", # 👨🏼‍❤‍👨🏿 "\U0001f468\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fb", # 👨🏼‍❤️‍👨🏻 "\U0001f468\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fb", # 👨🏼‍❤‍👨🏻 "\U0001f468\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fe", # 👨🏼‍❤️‍👨🏾 "\U0001f468\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fe", # 👨🏼‍❤‍👨🏾 "\U0001f468\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fd", # 👨🏼‍❤️‍👨🏽 "\U0001f468\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fd", # 👨🏼‍❤‍👨🏽 "\U0001f468\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fd", # 👨🏽‍❤️‍👨🏽 "\U0001f468\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fd", # 👨🏽‍❤‍👨🏽 "\U0001f468\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3ff", # 👨🏽‍❤️‍👨🏿 "\U0001f468\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f468\U0001f3ff", # 👨🏽‍❤‍👨🏿 "\U0001f468\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fb", # 👨🏽‍❤️‍👨🏻 "\U0001f468\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fb", # 👨🏽‍❤‍👨🏻 "\U0001f468\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fe", # 👨🏽‍❤️‍👨🏾 "\U0001f468\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fe", # 👨🏽‍❤‍👨🏾 "\U0001f468\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fc", # 👨🏽‍❤️‍👨🏼 "\U0001f468\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fc", # 👨🏽‍❤‍👨🏼 "\U0001f491\U0001f3fe", # 💑🏾 "\U0001f491\U0001f3fc", # 💑🏼 "\U0001f491\U0001f3fd", # 💑🏽 "\U0001f9d1\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f9d1\U0001f3fb", # 🧑🏿‍❤️‍🧑🏻 "\U0001f9d1\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f9d1\U0001f3fb", # 🧑🏿‍❤‍🧑🏻 "\U0001f9d1\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f9d1\U0001f3fe", # 🧑🏿‍❤️‍🧑🏾 "\U0001f9d1\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f9d1\U0001f3fe", # 🧑🏿‍❤‍🧑🏾 "\U0001f9d1\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f9d1\U0001f3fc", # 🧑🏿‍❤️‍🧑🏼 "\U0001f9d1\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f9d1\U0001f3fc", # 🧑🏿‍❤‍🧑🏼 "\U0001f9d1\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f9d1\U0001f3fd", # 🧑🏿‍❤️‍🧑🏽 "\U0001f9d1\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f9d1\U0001f3fd", # 🧑🏿‍❤‍🧑🏽 "\U0001f9d1\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f9d1\U0001f3ff", # 🧑🏻‍❤️‍🧑🏿 "\U0001f9d1\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f9d1\U0001f3ff", # 🧑🏻‍❤‍🧑🏿 "\U0001f9d1\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f9d1\U0001f3fe", # 🧑🏻‍❤️‍🧑🏾 "\U0001f9d1\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f9d1\U0001f3fe", # 🧑🏻‍❤‍🧑🏾 "\U0001f9d1\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f9d1\U0001f3fc", # 🧑🏻‍❤️‍🧑🏼 "\U0001f9d1\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f9d1\U0001f3fc", # 🧑🏻‍❤‍🧑🏼 "\U0001f9d1\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f9d1\U0001f3fd", # 🧑🏻‍❤️‍🧑🏽 "\U0001f9d1\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f9d1\U0001f3fd", # 🧑🏻‍❤‍🧑🏽 "\U0001f9d1\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f9d1\U0001f3ff", # 🧑🏾‍❤️‍🧑🏿 "\U0001f9d1\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f9d1\U0001f3ff", # 🧑🏾‍❤‍🧑🏿 "\U0001f9d1\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f9d1\U0001f3fb", # 🧑🏾‍❤️‍🧑🏻 "\U0001f9d1\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f9d1\U0001f3fb", # 🧑🏾‍❤‍🧑🏻 "\U0001f9d1\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f9d1\U0001f3fc", # 🧑🏾‍❤️‍🧑🏼 "\U0001f9d1\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f9d1\U0001f3fc", # 🧑🏾‍❤‍🧑🏼 "\U0001f9d1\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f9d1\U0001f3fd", # 🧑🏾‍❤️‍🧑🏽 "\U0001f9d1\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f9d1\U0001f3fd", # 🧑🏾‍❤‍🧑🏽 "\U0001f9d1\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f9d1\U0001f3ff", # 🧑🏼‍❤️‍🧑🏿 "\U0001f9d1\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f9d1\U0001f3ff", # 🧑🏼‍❤‍🧑🏿 "\U0001f9d1\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f9d1\U0001f3fb", # 🧑🏼‍❤️‍🧑🏻 "\U0001f9d1\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f9d1\U0001f3fb", # 🧑🏼‍❤‍🧑🏻 "\U0001f9d1\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f9d1\U0001f3fe", # 🧑🏼‍❤️‍🧑🏾 "\U0001f9d1\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f9d1\U0001f3fe", # 🧑🏼‍❤‍🧑🏾 "\U0001f9d1\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f9d1\U0001f3fd", # 🧑🏼‍❤️‍🧑🏽 "\U0001f9d1\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f9d1\U0001f3fd", # 🧑🏼‍❤‍🧑🏽 "\U0001f9d1\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f9d1\U0001f3ff", # 🧑🏽‍❤️‍🧑🏿 "\U0001f9d1\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f9d1\U0001f3ff", # 🧑🏽‍❤‍🧑🏿 "\U0001f9d1\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f9d1\U0001f3fb", # 🧑🏽‍❤️‍🧑🏻 "\U0001f9d1\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f9d1\U0001f3fb", # 🧑🏽‍❤‍🧑🏻 "\U0001f9d1\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f9d1\U0001f3fe", # 🧑🏽‍❤️‍🧑🏾 "\U0001f9d1\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f9d1\U0001f3fe", # 🧑🏽‍❤‍🧑🏾 "\U0001f9d1\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f9d1\U0001f3fc", # 🧑🏽‍❤️‍🧑🏼 "\U0001f9d1\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f9d1\U0001f3fc", # 🧑🏽‍❤‍🧑🏼 "\U0001f469\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468", # 👩‍❤️‍👨 "\U0001f469\U0000200d\U00002764\U0000200d\U0001f468", # 👩‍❤‍👨 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3ff", # 👩🏿‍❤️‍👨🏿 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f468\U0001f3ff", # 👩🏿‍❤‍👨🏿 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fb", # 👩🏿‍❤️‍👨🏻 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fb", # 👩🏿‍❤‍👨🏻 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fe", # 👩🏿‍❤️‍👨🏾 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fe", # 👩🏿‍❤‍👨🏾 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fc", # 👩🏿‍❤️‍👨🏼 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fc", # 👩🏿‍❤‍👨🏼 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fd", # 👩🏿‍❤️‍👨🏽 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fd", # 👩🏿‍❤‍👨🏽 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fb", # 👩🏻‍❤️‍👨🏻 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fb", # 👩🏻‍❤‍👨🏻 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3ff", # 👩🏻‍❤️‍👨🏿 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f468\U0001f3ff", # 👩🏻‍❤‍👨🏿 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fe", # 👩🏻‍❤️‍👨🏾 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fe", # 👩🏻‍❤‍👨🏾 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fc", # 👩🏻‍❤️‍👨🏼 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fc", # 👩🏻‍❤‍👨🏼 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fd", # 👩🏻‍❤️‍👨🏽 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fd", # 👩🏻‍❤‍👨🏽 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fe", # 👩🏾‍❤️‍👨🏾 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fe", # 👩🏾‍❤‍👨🏾 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3ff", # 👩🏾‍❤️‍👨🏿 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f468\U0001f3ff", # 👩🏾‍❤‍👨🏿 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fb", # 👩🏾‍❤️‍👨🏻 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fb", # 👩🏾‍❤‍👨🏻 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fc", # 👩🏾‍❤️‍👨🏼 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fc", # 👩🏾‍❤‍👨🏼 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fd", # 👩🏾‍❤️‍👨🏽 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fd", # 👩🏾‍❤‍👨🏽 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fc", # 👩🏼‍❤️‍👨🏼 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fc", # 👩🏼‍❤‍👨🏼 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3ff", # 👩🏼‍❤️‍👨🏿 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f468\U0001f3ff", # 👩🏼‍❤‍👨🏿 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fb", # 👩🏼‍❤️‍👨🏻 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fb", # 👩🏼‍❤‍👨🏻 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fe", # 👩🏼‍❤️‍👨🏾 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fe", # 👩🏼‍❤‍👨🏾 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fd", # 👩🏼‍❤️‍👨🏽 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fd", # 👩🏼‍❤‍👨🏽 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fd", # 👩🏽‍❤️‍👨🏽 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fd", # 👩🏽‍❤‍👨🏽 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3ff", # 👩🏽‍❤️‍👨🏿 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f468\U0001f3ff", # 👩🏽‍❤‍👨🏿 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fb", # 👩🏽‍❤️‍👨🏻 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fb", # 👩🏽‍❤‍👨🏻 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fe", # 👩🏽‍❤️‍👨🏾 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fe", # 👩🏽‍❤‍👨🏾 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f468\U0001f3fc", # 👩🏽‍❤️‍👨🏼 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f468\U0001f3fc", # 👩🏽‍❤‍👨🏼 "\U0001f469\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469", # 👩‍❤️‍👩 "\U0001f469\U0000200d\U00002764\U0000200d\U0001f469", # 👩‍❤‍👩 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3ff", # 👩🏿‍❤️‍👩🏿 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f469\U0001f3ff", # 👩🏿‍❤‍👩🏿 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3fb", # 👩🏿‍❤️‍👩🏻 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f469\U0001f3fb", # 👩🏿‍❤‍👩🏻 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3fe", # 👩🏿‍❤️‍👩🏾 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f469\U0001f3fe", # 👩🏿‍❤‍👩🏾 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3fc", # 👩🏿‍❤️‍👩🏼 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f469\U0001f3fc", # 👩🏿‍❤‍👩🏼 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3fd", # 👩🏿‍❤️‍👩🏽 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f469\U0001f3fd", # 👩🏿‍❤‍👩🏽 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3fb", # 👩🏻‍❤️‍👩🏻 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f469\U0001f3fb", # 👩🏻‍❤‍👩🏻 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3ff", # 👩🏻‍❤️‍👩🏿 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f469\U0001f3ff", # 👩🏻‍❤‍👩🏿 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3fe", # 👩🏻‍❤️‍👩🏾 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f469\U0001f3fe", # 👩🏻‍❤‍👩🏾 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3fc", # 👩🏻‍❤️‍👩🏼 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f469\U0001f3fc", # 👩🏻‍❤‍👩🏼 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3fd", # 👩🏻‍❤️‍👩🏽 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f469\U0001f3fd", # 👩🏻‍❤‍👩🏽 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3fe", # 👩🏾‍❤️‍👩🏾 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f469\U0001f3fe", # 👩🏾‍❤‍👩🏾 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3ff", # 👩🏾‍❤️‍👩🏿 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f469\U0001f3ff", # 👩🏾‍❤‍👩🏿 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3fb", # 👩🏾‍❤️‍👩🏻 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f469\U0001f3fb", # 👩🏾‍❤‍👩🏻 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3fc", # 👩🏾‍❤️‍👩🏼 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f469\U0001f3fc", # 👩🏾‍❤‍👩🏼 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3fd", # 👩🏾‍❤️‍👩🏽 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f469\U0001f3fd", # 👩🏾‍❤‍👩🏽 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3fc", # 👩🏼‍❤️‍👩🏼 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f469\U0001f3fc", # 👩🏼‍❤‍👩🏼 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3ff", # 👩🏼‍❤️‍👩🏿 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f469\U0001f3ff", # 👩🏼‍❤‍👩🏿 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3fb", # 👩🏼‍❤️‍👩🏻 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f469\U0001f3fb", # 👩🏼‍❤‍👩🏻 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3fe", # 👩🏼‍❤️‍👩🏾 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f469\U0001f3fe", # 👩🏼‍❤‍👩🏾 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3fd", # 👩🏼‍❤️‍👩🏽 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f469\U0001f3fd", # 👩🏼‍❤‍👩🏽 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3fd", # 👩🏽‍❤️‍👩🏽 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f469\U0001f3fd", # 👩🏽‍❤‍👩🏽 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3ff", # 👩🏽‍❤️‍👩🏿 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f469\U0001f3ff", # 👩🏽‍❤‍👩🏿 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3fb", # 👩🏽‍❤️‍👩🏻 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f469\U0001f3fb", # 👩🏽‍❤‍👩🏻 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3fe", # 👩🏽‍❤️‍👩🏾 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f469\U0001f3fe", # 👩🏽‍❤‍👩🏾 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f469\U0001f3fc", # 👩🏽‍❤️‍👩🏼 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f469\U0001f3fc", # 👩🏽‍❤‍👩🏼 "\U0001f404", # 🐄 "\U0001f42e", # 🐮 "\U0001f920", # 🤠 "\U0001f980", # 🦀 "\U0001f58d\U0000fe0f", # 🖍️ "\U0001f58d", # 🖍 "\U0001f4b3", # 💳 "\U0001f319", # 🌙 "\U0001f997", # 🦗 "\U0001f3cf", # 🏏 "\U0001f40a", # 🐊 "\U0001f950", # 🥐 "\U0000274c", # ❌ "\U0000274e", # ❎ "\U0001f91e", # 🤞 "\U0001f91e\U0001f3ff", # 🤞🏿 "\U0001f91e\U0001f3fb", # 🤞🏻 "\U0001f91e\U0001f3fe", # 🤞🏾 "\U0001f91e\U0001f3fc", # 🤞🏼 "\U0001f91e\U0001f3fd", # 🤞🏽 "\U0001f38c", # 🎌 "\U00002694\U0000fe0f", # ⚔️ "\U00002694", # ⚔ "\U0001f451", # 👑 "\U0001fa7c", # 🩼 "\U0001f63f", # 😿 "\U0001f622", # 😢 "\U0001f52e", # 🔮 "\U0001f952", # 🥒 "\U0001f964", # 🥤 "\U0001f9c1", # 🧁 "\U0001f94c", # 🥌 "\U0001f9b1", # 🦱 "\U000027b0", # ➰ "\U0001f4b1", # 💱 "\U0001f35b", # 🍛 "\U0001f36e", # 🍮 "\U0001f6c3", # 🛃 "\U0001f969", # 🥩 "\U0001f300", # 🌀 "\U0001f5e1\U0000fe0f", # 🗡️ "\U0001f5e1", # 🗡 "\U0001f361", # 🍡 "\U0001f3ff", # 🏿 "\U0001f4a8", # 💨 "\U0001f9cf\U0000200d\U00002642\U0000fe0f", # 🧏‍♂️ "\U0001f9cf\U0000200d\U00002642", # 🧏‍♂ "\U0001f9cf\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🧏🏿‍♂️ "\U0001f9cf\U0001f3ff\U0000200d\U00002642", # 🧏🏿‍♂ "\U0001f9cf\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🧏🏻‍♂️ "\U0001f9cf\U0001f3fb\U0000200d\U00002642", # 🧏🏻‍♂ "\U0001f9cf\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🧏🏾‍♂️ "\U0001f9cf\U0001f3fe\U0000200d\U00002642", # 🧏🏾‍♂ "\U0001f9cf\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🧏🏼‍♂️ "\U0001f9cf\U0001f3fc\U0000200d\U00002642", # 🧏🏼‍♂ "\U0001f9cf\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🧏🏽‍♂️ "\U0001f9cf\U0001f3fd\U0000200d\U00002642", # 🧏🏽‍♂ "\U0001f9cf", # 🧏 "\U0001f9cf\U0001f3ff", # 🧏🏿 "\U0001f9cf\U0001f3fb", # 🧏🏻 "\U0001f9cf\U0001f3fe", # 🧏🏾 "\U0001f9cf\U0001f3fc", # 🧏🏼 "\U0001f9cf\U0001f3fd", # 🧏🏽 "\U0001f9cf\U0000200d\U00002640\U0000fe0f", # 🧏‍♀️ "\U0001f9cf\U0000200d\U00002640", # 🧏‍♀ "\U0001f9cf\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🧏🏿‍♀️ "\U0001f9cf\U0001f3ff\U0000200d\U00002640", # 🧏🏿‍♀ "\U0001f9cf\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🧏🏻‍♀️ "\U0001f9cf\U0001f3fb\U0000200d\U00002640", # 🧏🏻‍♀ "\U0001f9cf\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🧏🏾‍♀️ "\U0001f9cf\U0001f3fe\U0000200d\U00002640", # 🧏🏾‍♀ "\U0001f9cf\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🧏🏼‍♀️ "\U0001f9cf\U0001f3fc\U0000200d\U00002640", # 🧏🏼‍♀ "\U0001f9cf\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🧏🏽‍♀️ "\U0001f9cf\U0001f3fd\U0000200d\U00002640", # 🧏🏽‍♀ "\U0001f333", # 🌳 "\U0001f98c", # 🦌 "\U0001f69a", # 🚚 "\U0001f3ec", # 🏬 "\U0001f3da\U0000fe0f", # 🏚️ "\U0001f3da", # 🏚 "\U0001f3dc\U0000fe0f", # 🏜️ "\U0001f3dc", # 🏜 "\U0001f3dd\U0000fe0f", # 🏝️ "\U0001f3dd", # 🏝 "\U0001f5a5\U0000fe0f", # 🖥️ "\U0001f5a5", # 🖥 "\U0001f575\U0000fe0f", # 🕵️ "\U0001f575", # 🕵 "\U0001f575\U0001f3ff", # 🕵🏿 "\U0001f575\U0001f3fb", # 🕵🏻 "\U0001f575\U0001f3fe", # 🕵🏾 "\U0001f575\U0001f3fc", # 🕵🏼 "\U0001f575\U0001f3fd", # 🕵🏽 "\U00002666\U0000fe0f", # ♦️ "\U00002666", # ♦ "\U0001f4a0", # 💠 "\U0001f505", # 🔅 "\U0001f61e", # 😞 "\U0001f978", # 🥸 "\U00002797", # ➗ "\U0001f93f", # 🤿 "\U0001fa94", # 🪔 "\U0001f4ab", # 💫 "\U0001f9ec", # 🧬 "\U0001f9a4", # 🦤 "\U0001f415", # 🐕 "\U0001f436", # 🐶 "\U0001f4b5", # 💵 "\U0001f42c", # 🐬 "\U0001facf", # 🫏 "\U0001f6aa", # 🚪 "\U0001fae5", # 🫥 "\U0001f52f", # 🔯 "\U000027bf", # ➿ "\U0000203c\U0000fe0f", # ‼️ "\U0000203c", # ‼ "\U0001f369", # 🍩 "\U0001f54a\U0000fe0f", # 🕊️ "\U0001f54a", # 🕊 "\U00002199\U0000fe0f", # ↙️ "\U00002199", # ↙ "\U00002198\U0000fe0f", # ↘️ "\U00002198", # ↘ "\U00002b07\U0000fe0f", # ⬇️ "\U00002b07", # ⬇ "\U0001f613", # 😓 "\U0001f53d", # 🔽 "\U0001f409", # 🐉 "\U0001f432", # 🐲 "\U0001f457", # 👗 "\U0001f924", # 🤤 "\U0001fa78", # 🩸 "\U0001f4a7", # 💧 "\U0001f941", # 🥁 "\U0001f986", # 🦆 "\U0001f95f", # 🥟 "\U0001f4c0", # 📀 "\U0001f4e7", # 📧 "\U0001f985", # 🦅 "\U0001f442", # 👂 "\U0001f442\U0001f3ff", # 👂🏿 "\U0001f442\U0001f3fb", # 👂🏻 "\U0001f442\U0001f3fe", # 👂🏾 "\U0001f442\U0001f3fc", # 👂🏼 "\U0001f442\U0001f3fd", # 👂🏽 "\U0001f33d", # 🌽 "\U0001f9bb", # 🦻 "\U0001f9bb\U0001f3ff", # 🦻🏿 "\U0001f9bb\U0001f3fb", # 🦻🏻 "\U0001f9bb\U0001f3fe", # 🦻🏾 "\U0001f9bb\U0001f3fc", # 🦻🏼 "\U0001f9bb\U0001f3fd", # 🦻🏽 "\U0001f95a", # 🥚 "\U0001f346", # 🍆 "\U00002734\U0000fe0f", # ✴️ "\U00002734", # ✴ "\U00002733\U0000fe0f", # ✳️ "\U00002733", # ✳ "\U0001f563", # 🕣 "\U0001f557", # 🕗 "\U000023cf\U0000fe0f", # ⏏️ "\U000023cf", # ⏏ "\U0001f50c", # 🔌 "\U0001f418", # 🐘 "\U0001f6d7", # 🛗 "\U0001f566", # 🕦 "\U0001f55a", # 🕚 "\U0001f9dd", # 🧝 "\U0001f9dd\U0001f3ff", # 🧝🏿 "\U0001f9dd\U0001f3fb", # 🧝🏻 "\U0001f9dd\U0001f3fe", # 🧝🏾 "\U0001f9dd\U0001f3fc", # 🧝🏼 "\U0001f9dd\U0001f3fd", # 🧝🏽 "\U0001fab9", # 🪹 "\U0001f621", # 😡 "\U00002709\U0000fe0f", # ✉️ "\U00002709", # ✉ "\U0001f4e9", # 📩 "\U0001f4b6", # 💶 "\U0001f332", # 🌲 "\U0001f411", # 🐑 "\U00002049\U0000fe0f", # ⁉️ "\U00002049", # ⁉ "\U0001f92f", # 🤯 "\U0001f611", # 😑 "\U0001f441\U0000fe0f", # 👁️ "\U0001f441", # 👁 "\U0001f441\U0000fe0f\U0000200d\U0001f5e8\U0000fe0f", # 👁️‍🗨️ "\U0001f441\U0000200d\U0001f5e8\U0000fe0f", # 👁‍🗨️ "\U0001f441\U0000fe0f\U0000200d\U0001f5e8", # 👁️‍🗨 "\U0001f441\U0000200d\U0001f5e8", # 👁‍🗨 "\U0001f440", # 👀 "\U0001f618", # 😘 "\U0001f62e\U0000200d\U0001f4a8", # 😮‍💨 "\U0001f979", # 🥹 "\U0001f636\U0000200d\U0001f32b\U0000fe0f", # 😶‍🌫️ "\U0001f636\U0000200d\U0001f32b", # 😶‍🌫 "\U0001f60b", # 😋 "\U0001f631", # 😱 "\U0001f92e", # 🤮 "\U0001fae9", # 🫩 "\U0001f635", # 😵 "\U0001fae4", # 🫤 "\U0001f92d", # 🤭 "\U0001f915", # 🤕 "\U0001f637", # 😷 "\U0001f9d0", # 🧐 "\U0001fae2", # 🫢 "\U0001f62e", # 😮 "\U0001fae3", # 🫣 "\U0001f928", # 🤨 "\U0001f644", # 🙄 "\U0001f635\U0000200d\U0001f4ab", # 😵‍💫 "\U0001f624", # 😤 "\U0001f92c", # 🤬 "\U0001f602", # 😂 "\U0001f912", # 🤒 "\U0001f61b", # 😛 "\U0001f636", # 😶 "\U0001f3ed", # 🏭 "\U0001f9d1\U0000200d\U0001f3ed", # 🧑‍🏭 "\U0001f9d1\U0001f3ff\U0000200d\U0001f3ed", # 🧑🏿‍🏭 "\U0001f9d1\U0001f3fb\U0000200d\U0001f3ed", # 🧑🏻‍🏭 "\U0001f9d1\U0001f3fe\U0000200d\U0001f3ed", # 🧑🏾‍🏭 "\U0001f9d1\U0001f3fc\U0000200d\U0001f3ed", # 🧑🏼‍🏭 "\U0001f9d1\U0001f3fd\U0000200d\U0001f3ed", # 🧑🏽‍🏭 "\U0001f9da", # 🧚 "\U0001f9da\U0001f3ff", # 🧚🏿 "\U0001f9da\U0001f3fb", # 🧚🏻 "\U0001f9da\U0001f3fe", # 🧚🏾 "\U0001f9da\U0001f3fc", # 🧚🏼 "\U0001f9da\U0001f3fd", # 🧚🏽 "\U0001f9c6", # 🧆 "\U0001f342", # 🍂 "\U0001f46a", # 👪 "\U0001f9d1\U0000200d\U0001f9d1\U0000200d\U0001f9d2", # 🧑‍🧑‍🧒 "\U0001f9d1\U0000200d\U0001f9d1\U0000200d\U0001f9d2\U0000200d\U0001f9d2", # 🧑‍🧑‍🧒‍🧒 "\U0001f9d1\U0000200d\U0001f9d2", # 🧑‍🧒 "\U0001f9d1\U0000200d\U0001f9d2\U0000200d\U0001f9d2", # 🧑‍🧒‍🧒 "\U0001f468\U0000200d\U0001f466", # 👨‍👦 "\U0001f468\U0000200d\U0001f466\U0000200d\U0001f466", # 👨‍👦‍👦 "\U0001f468\U0000200d\U0001f467", # 👨‍👧 "\U0001f468\U0000200d\U0001f467\U0000200d\U0001f466", # 👨‍👧‍👦 "\U0001f468\U0000200d\U0001f467\U0000200d\U0001f467", # 👨‍👧‍👧 "\U0001f468\U0000200d\U0001f468\U0000200d\U0001f466", # 👨‍👨‍👦 "\U0001f468\U0000200d\U0001f468\U0000200d\U0001f466\U0000200d\U0001f466", # 👨‍👨‍👦‍👦 "\U0001f468\U0000200d\U0001f468\U0000200d\U0001f467", # 👨‍👨‍👧 "\U0001f468\U0000200d\U0001f468\U0000200d\U0001f467\U0000200d\U0001f466", # 👨‍👨‍👧‍👦 "\U0001f468\U0000200d\U0001f468\U0000200d\U0001f467\U0000200d\U0001f467", # 👨‍👨‍👧‍👧 "\U0001f468\U0000200d\U0001f469\U0000200d\U0001f466", # 👨‍👩‍👦 "\U0001f468\U0000200d\U0001f469\U0000200d\U0001f466\U0000200d\U0001f466", # 👨‍👩‍👦‍👦 "\U0001f468\U0000200d\U0001f469\U0000200d\U0001f467", # 👨‍👩‍👧 "\U0001f468\U0000200d\U0001f469\U0000200d\U0001f467\U0000200d\U0001f466", # 👨‍👩‍👧‍👦 "\U0001f468\U0000200d\U0001f469\U0000200d\U0001f467\U0000200d\U0001f467", # 👨‍👩‍👧‍👧 "\U0001f469\U0000200d\U0001f466", # 👩‍👦 "\U0001f469\U0000200d\U0001f466\U0000200d\U0001f466", # 👩‍👦‍👦 "\U0001f469\U0000200d\U0001f467", # 👩‍👧 "\U0001f469\U0000200d\U0001f467\U0000200d\U0001f466", # 👩‍👧‍👦 "\U0001f469\U0000200d\U0001f467\U0000200d\U0001f467", # 👩‍👧‍👧 "\U0001f469\U0000200d\U0001f469\U0000200d\U0001f466", # 👩‍👩‍👦 "\U0001f469\U0000200d\U0001f469\U0000200d\U0001f466\U0000200d\U0001f466", # 👩‍👩‍👦‍👦 "\U0001f469\U0000200d\U0001f469\U0000200d\U0001f467", # 👩‍👩‍👧 "\U0001f469\U0000200d\U0001f469\U0000200d\U0001f467\U0000200d\U0001f466", # 👩‍👩‍👧‍👦 "\U0001f469\U0000200d\U0001f469\U0000200d\U0001f467\U0000200d\U0001f467", # 👩‍👩‍👧‍👧 "\U0001f9d1\U0000200d\U0001f33e", # 🧑‍🌾 "\U0001f9d1\U0001f3ff\U0000200d\U0001f33e", # 🧑🏿‍🌾 "\U0001f9d1\U0001f3fb\U0000200d\U0001f33e", # 🧑🏻‍🌾 "\U0001f9d1\U0001f3fe\U0000200d\U0001f33e", # 🧑🏾‍🌾 "\U0001f9d1\U0001f3fc\U0000200d\U0001f33e", # 🧑🏼‍🌾 "\U0001f9d1\U0001f3fd\U0000200d\U0001f33e", # 🧑🏽‍🌾 "\U000023e9", # ⏩ "\U000023ec", # ⏬ "\U000023ea", # ⏪ "\U000023eb", # ⏫ "\U0001f4e0", # 📠 "\U0001f628", # 😨 "\U0001fab6", # 🪶 "\U00002640\U0000fe0f", # ♀️ "\U00002640", # ♀ "\U0001f3a1", # 🎡 "\U000026f4\U0000fe0f", # ⛴️ "\U000026f4", # ⛴ "\U0001f3d1", # 🏑 "\U0001f5c4\U0000fe0f", # 🗄️ "\U0001f5c4", # 🗄 "\U0001f4c1", # 📁 "\U0001f39e\U0000fe0f", # 🎞️ "\U0001f39e", # 🎞 "\U0001f4fd\U0000fe0f", # 📽️ "\U0001f4fd", # 📽 "\U0001fac6", # 🫆 "\U0001f525", # 🔥 "\U0001f692", # 🚒 "\U0001f9ef", # 🧯 "\U0001f9e8", # 🧨 "\U0001f9d1\U0000200d\U0001f692", # 🧑‍🚒 "\U0001f9d1\U0001f3ff\U0000200d\U0001f692", # 🧑🏿‍🚒 "\U0001f9d1\U0001f3fb\U0000200d\U0001f692", # 🧑🏻‍🚒 "\U0001f9d1\U0001f3fe\U0000200d\U0001f692", # 🧑🏾‍🚒 "\U0001f9d1\U0001f3fc\U0000200d\U0001f692", # 🧑🏼‍🚒 "\U0001f9d1\U0001f3fd\U0000200d\U0001f692", # 🧑🏽‍🚒 "\U0001f386", # 🎆 "\U0001f313", # 🌓 "\U0001f31b", # 🌛 "\U0001f41f", # 🐟 "\U0001f365", # 🍥 "\U0001f3a3", # 🎣 "\U0001f560", # 🕠 "\U0001f554", # 🕔 "\U000026f3", # ⛳ "\U0001f9a9", # 🦩 "\U0001f526", # 🔦 "\U0001f97f", # 🥿 "\U0001fad3", # 🫓 "\U0000269c\U0000fe0f", # ⚜️ "\U0000269c", # ⚜ "\U0001f4aa", # 💪 "\U0001f4aa\U0001f3ff", # 💪🏿 "\U0001f4aa\U0001f3fb", # 💪🏻 "\U0001f4aa\U0001f3fe", # 💪🏾 "\U0001f4aa\U0001f3fc", # 💪🏼 "\U0001f4aa\U0001f3fd", # 💪🏽 "\U0001f4be", # 💾 "\U0001f3b4", # 🎴 "\U0001f633", # 😳 "\U0001fa88", # 🪈 "\U0001fab0", # 🪰 "\U0001f94f", # 🥏 "\U0001f6f8", # 🛸 "\U0001f32b\U0000fe0f", # 🌫️ "\U0001f32b", # 🌫 "\U0001f301", # 🌁 "\U0001f64f", # 🙏 "\U0001f64f\U0001f3ff", # 🙏🏿 "\U0001f64f\U0001f3fb", # 🙏🏻 "\U0001f64f\U0001f3fe", # 🙏🏾 "\U0001f64f\U0001f3fc", # 🙏🏼 "\U0001f64f\U0001f3fd", # 🙏🏽 "\U0001faad", # 🪭 "\U0001fad5", # 🫕 "\U0001f9b6", # 🦶 "\U0001f9b6\U0001f3ff", # 🦶🏿 "\U0001f9b6\U0001f3fb", # 🦶🏻 "\U0001f9b6\U0001f3fe", # 🦶🏾 "\U0001f9b6\U0001f3fc", # 🦶🏼 "\U0001f9b6\U0001f3fd", # 🦶🏽 "\U0001f463", # 👣 "\U0001f374", # 🍴 "\U0001f37d\U0000fe0f", # 🍽️ "\U0001f37d", # 🍽 "\U0001f960", # 🥠 "\U000026f2", # ⛲ "\U0001f58b\U0000fe0f", # 🖋️ "\U0001f58b", # 🖋 "\U0001f55f", # 🕟 "\U0001f340", # 🍀 "\U0001f553", # 🕓 "\U0001f98a", # 🦊 "\U0001f5bc\U0000fe0f", # 🖼️ "\U0001f5bc", # 🖼 "\U0001f35f", # 🍟 "\U0001f364", # 🍤 "\U0001f438", # 🐸 "\U0001f425", # 🐥 "\U00002639\U0000fe0f", # ☹️ "\U00002639", # ☹ "\U0001f626", # 😦 "\U000026fd", # ⛽ "\U0001f315", # 🌕 "\U0001f31d", # 🌝 "\U000026b1\U0000fe0f", # ⚱️ "\U000026b1", # ⚱ "\U0001f3b2", # 🎲 "\U0001f9c4", # 🧄 "\U00002699\U0000fe0f", # ⚙️ "\U00002699", # ⚙ "\U0001f48e", # 💎 "\U0001f9de", # 🧞 "\U0001f47b", # 👻 "\U0001fada", # 🫚 "\U0001f992", # 🦒 "\U0001f467", # 👧 "\U0001f467\U0001f3ff", # 👧🏿 "\U0001f467\U0001f3fb", # 👧🏻 "\U0001f467\U0001f3fe", # 👧🏾 "\U0001f467\U0001f3fc", # 👧🏼 "\U0001f467\U0001f3fd", # 👧🏽 "\U0001f95b", # 🥛 "\U0001f453", # 👓 "\U0001f30e", # 🌎 "\U0001f30f", # 🌏 "\U0001f30d", # 🌍 "\U0001f310", # 🌐 "\U0001f9e4", # 🧤 "\U0001f31f", # 🌟 "\U0001f945", # 🥅 "\U0001f410", # 🐐 "\U0001f47a", # 👺 "\U0001f97d", # 🥽 "\U0001fabf", # 🪿 "\U0001f98d", # 🦍 "\U0001f393", # 🎓 "\U0001f347", # 🍇 "\U0001f34f", # 🍏 "\U0001f4d7", # 📗 "\U0001f7e2", # 🟢 "\U0001f49a", # 💚 "\U0001f957", # 🥗 "\U0001f7e9", # 🟩 "\U0001fa76", # 🩶 "\U0001f62c", # 😬 "\U0001f63a", # 😺 "\U0001f638", # 😸 "\U0001f600", # 😀 "\U0001f603", # 😃 "\U0001f604", # 😄 "\U0001f605", # 😅 "\U0001f606", # 😆 "\U0001f497", # 💗 "\U0001f482", # 💂 "\U0001f482\U0001f3ff", # 💂🏿 "\U0001f482\U0001f3fb", # 💂🏻 "\U0001f482\U0001f3fe", # 💂🏾 "\U0001f482\U0001f3fc", # 💂🏼 "\U0001f482\U0001f3fd", # 💂🏽 "\U0001f9ae", # 🦮 "\U0001f3b8", # 🎸 "\U0001faae", # 🪮 "\U0001f354", # 🍔 "\U0001f528", # 🔨 "\U00002692\U0000fe0f", # ⚒️ "\U00002692", # ⚒ "\U0001f6e0\U0000fe0f", # 🛠️ "\U0001f6e0", # 🛠 "\U0001faac", # 🪬 "\U0001f439", # 🐹 "\U0001f590\U0000fe0f", # 🖐️ "\U0001f590", # 🖐 "\U0001f590\U0001f3ff", # 🖐🏿 "\U0001f590\U0001f3fb", # 🖐🏻 "\U0001f590\U0001f3fe", # 🖐🏾 "\U0001f590\U0001f3fc", # 🖐🏼 "\U0001f590\U0001f3fd", # 🖐🏽 "\U0001faf0", # 🫰 "\U0001faf0\U0001f3ff", # 🫰🏿 "\U0001faf0\U0001f3fb", # 🫰🏻 "\U0001faf0\U0001f3fe", # 🫰🏾 "\U0001faf0\U0001f3fc", # 🫰🏼 "\U0001faf0\U0001f3fd", # 🫰🏽 "\U0001f45c", # 👜 "\U0001f91d", # 🤝 "\U0001f91d\U0001f3ff", # 🤝🏿 "\U0001faf1\U0001f3ff\U0000200d\U0001faf2\U0001f3fb", # 🫱🏿‍🫲🏻 "\U0001faf1\U0001f3ff\U0000200d\U0001faf2\U0001f3fe", # 🫱🏿‍🫲🏾 "\U0001faf1\U0001f3ff\U0000200d\U0001faf2\U0001f3fc", # 🫱🏿‍🫲🏼 "\U0001faf1\U0001f3ff\U0000200d\U0001faf2\U0001f3fd", # 🫱🏿‍🫲🏽 "\U0001f91d\U0001f3fb", # 🤝🏻 "\U0001faf1\U0001f3fb\U0000200d\U0001faf2\U0001f3ff", # 🫱🏻‍🫲🏿 "\U0001faf1\U0001f3fb\U0000200d\U0001faf2\U0001f3fe", # 🫱🏻‍🫲🏾 "\U0001faf1\U0001f3fb\U0000200d\U0001faf2\U0001f3fc", # 🫱🏻‍🫲🏼 "\U0001faf1\U0001f3fb\U0000200d\U0001faf2\U0001f3fd", # 🫱🏻‍🫲🏽 "\U0001f91d\U0001f3fe", # 🤝🏾 "\U0001faf1\U0001f3fe\U0000200d\U0001faf2\U0001f3ff", # 🫱🏾‍🫲🏿 "\U0001faf1\U0001f3fe\U0000200d\U0001faf2\U0001f3fb", # 🫱🏾‍🫲🏻 "\U0001faf1\U0001f3fe\U0000200d\U0001faf2\U0001f3fc", # 🫱🏾‍🫲🏼 "\U0001faf1\U0001f3fe\U0000200d\U0001faf2\U0001f3fd", # 🫱🏾‍🫲🏽 "\U0001f91d\U0001f3fc", # 🤝🏼 "\U0001faf1\U0001f3fc\U0000200d\U0001faf2\U0001f3ff", # 🫱🏼‍🫲🏿 "\U0001faf1\U0001f3fc\U0000200d\U0001faf2\U0001f3fb", # 🫱🏼‍🫲🏻 "\U0001faf1\U0001f3fc\U0000200d\U0001faf2\U0001f3fe", # 🫱🏼‍🫲🏾 "\U0001faf1\U0001f3fc\U0000200d\U0001faf2\U0001f3fd", # 🫱🏼‍🫲🏽 "\U0001f91d\U0001f3fd", # 🤝🏽 "\U0001faf1\U0001f3fd\U0000200d\U0001faf2\U0001f3ff", # 🫱🏽‍🫲🏿 "\U0001faf1\U0001f3fd\U0000200d\U0001faf2\U0001f3fb", # 🫱🏽‍🫲🏻 "\U0001faf1\U0001f3fd\U0000200d\U0001faf2\U0001f3fe", # 🫱🏽‍🫲🏾 "\U0001faf1\U0001f3fd\U0000200d\U0001faf2\U0001f3fc", # 🫱🏽‍🫲🏼 "\U0001fa89", # 🪉 "\U0001f423", # 🐣 "\U0001f642\U0000200d\U00002194\U0000fe0f", # 🙂‍↔️ "\U0001f642\U0000200d\U00002194", # 🙂‍↔ "\U0001f642\U0000200d\U00002195\U0000fe0f", # 🙂‍↕️ "\U0001f642\U0000200d\U00002195", # 🙂‍↕ "\U0001f3a7", # 🎧 "\U0001faa6", # 🪦 "\U0001f9d1\U0000200d\U00002695\U0000fe0f", # 🧑‍⚕️ "\U0001f9d1\U0000200d\U00002695", # 🧑‍⚕ "\U0001f9d1\U0001f3ff\U0000200d\U00002695\U0000fe0f", # 🧑🏿‍⚕️ "\U0001f9d1\U0001f3ff\U0000200d\U00002695", # 🧑🏿‍⚕ "\U0001f9d1\U0001f3fb\U0000200d\U00002695\U0000fe0f", # 🧑🏻‍⚕️ "\U0001f9d1\U0001f3fb\U0000200d\U00002695", # 🧑🏻‍⚕ "\U0001f9d1\U0001f3fe\U0000200d\U00002695\U0000fe0f", # 🧑🏾‍⚕️ "\U0001f9d1\U0001f3fe\U0000200d\U00002695", # 🧑🏾‍⚕ "\U0001f9d1\U0001f3fc\U0000200d\U00002695\U0000fe0f", # 🧑🏼‍⚕️ "\U0001f9d1\U0001f3fc\U0000200d\U00002695", # 🧑🏼‍⚕ "\U0001f9d1\U0001f3fd\U0000200d\U00002695\U0000fe0f", # 🧑🏽‍⚕️ "\U0001f9d1\U0001f3fd\U0000200d\U00002695", # 🧑🏽‍⚕ "\U0001f649", # 🙉 "\U0001f49f", # 💟 "\U00002763\U0000fe0f", # ❣️ "\U00002763", # ❣ "\U0001faf6", # 🫶 "\U0001faf6\U0001f3ff", # 🫶🏿 "\U0001faf6\U0001f3fb", # 🫶🏻 "\U0001faf6\U0001f3fe", # 🫶🏾 "\U0001faf6\U0001f3fc", # 🫶🏼 "\U0001faf6\U0001f3fd", # 🫶🏽 "\U00002764\U0000fe0f\U0000200d\U0001f525", # ❤️‍🔥 "\U00002764\U0000200d\U0001f525", # ❤‍🔥 "\U00002665\U0000fe0f", # ♥️ "\U00002665", # ♥ "\U0001f498", # 💘 "\U0001f49d", # 💝 "\U0001f4b2", # 💲 "\U0001f7f0", # 🟰 "\U0001f994", # 🦔 "\U0001f681", # 🚁 "\U0001f33f", # 🌿 "\U0001f33a", # 🌺 "\U0001f460", # 👠 "\U0001f684", # 🚄 "\U000026a1", # ⚡ "\U0001f97e", # 🥾 "\U0001f6d5", # 🛕 "\U0001f99b", # 🦛 "\U0001f573\U0000fe0f", # 🕳️ "\U0001f573", # 🕳 "\U00002b55", # ⭕ "\U0001f36f", # 🍯 "\U0001f41d", # 🐝 "\U0001fa9d", # 🪝 "\U0001f6a5", # 🚥 "\U0001f40e", # 🐎 "\U0001f434", # 🐴 "\U0001f3c7", # 🏇 "\U0001f3c7\U0001f3ff", # 🏇🏿 "\U0001f3c7\U0001f3fb", # 🏇🏻 "\U0001f3c7\U0001f3fe", # 🏇🏾 "\U0001f3c7\U0001f3fc", # 🏇🏼 "\U0001f3c7\U0001f3fd", # 🏇🏽 "\U0001f3e5", # 🏥 "\U00002615", # ☕ "\U0001f32d", # 🌭 "\U0001f975", # 🥵 "\U0001f336\U0000fe0f", # 🌶️ "\U0001f336", # 🌶 "\U00002668\U0000fe0f", # ♨️ "\U00002668", # ♨ "\U0001f3e8", # 🏨 "\U0000231b", # ⌛ "\U000023f3", # ⏳ "\U0001f3e0", # 🏠 "\U0001f3e1", # 🏡 "\U0001f3d8\U0000fe0f", # 🏘️ "\U0001f3d8", # 🏘 "\U0001f4af", # 💯 "\U0001f62f", # 😯 "\U0001f6d6", # 🛖 "\U0001fabb", # 🪻 "\U0001f9ca", # 🧊 "\U0001f368", # 🍨 "\U0001f3d2", # 🏒 "\U000026f8\U0000fe0f", # ⛸️ "\U000026f8", # ⛸ "\U0001faaa", # 🪪 "\U0001f4e5", # 📥 "\U0001f4e8", # 📨 "\U0001faf5", # 🫵 "\U0001faf5\U0001f3ff", # 🫵🏿 "\U0001faf5\U0001f3fb", # 🫵🏻 "\U0001faf5\U0001f3fe", # 🫵🏾 "\U0001faf5\U0001f3fc", # 🫵🏼 "\U0001faf5\U0001f3fd", # 🫵🏽 "\U0000261d\U0000fe0f", # ☝️ "\U0000261d", # ☝ "\U0000261d\U0001f3ff", # ☝🏿 "\U0000261d\U0001f3fb", # ☝🏻 "\U0000261d\U0001f3fe", # ☝🏾 "\U0000261d\U0001f3fc", # ☝🏼 "\U0000261d\U0001f3fd", # ☝🏽 "\U0000267e\U0000fe0f", # ♾️ "\U0000267e", # ♾ "\U00002139\U0000fe0f", # ℹ️ "\U00002139", # ℹ "\U0001f524", # 🔤 "\U0001f521", # 🔡 "\U0001f520", # 🔠 "\U0001f522", # 🔢 "\U0001f523", # 🔣 "\U0001f383", # 🎃 "\U0001fad9", # 🫙 "\U0001f456", # 👖 "\U0001fabc", # 🪼 "\U0001f0cf", # 🃏 "\U0001f579\U0000fe0f", # 🕹️ "\U0001f579", # 🕹 "\U0001f9d1\U0000200d\U00002696\U0000fe0f", # 🧑‍⚖️ "\U0001f9d1\U0000200d\U00002696", # 🧑‍⚖ "\U0001f9d1\U0001f3ff\U0000200d\U00002696\U0000fe0f", # 🧑🏿‍⚖️ "\U0001f9d1\U0001f3ff\U0000200d\U00002696", # 🧑🏿‍⚖ "\U0001f9d1\U0001f3fb\U0000200d\U00002696\U0000fe0f", # 🧑🏻‍⚖️ "\U0001f9d1\U0001f3fb\U0000200d\U00002696", # 🧑🏻‍⚖ "\U0001f9d1\U0001f3fe\U0000200d\U00002696\U0000fe0f", # 🧑🏾‍⚖️ "\U0001f9d1\U0001f3fe\U0000200d\U00002696", # 🧑🏾‍⚖ "\U0001f9d1\U0001f3fc\U0000200d\U00002696\U0000fe0f", # 🧑🏼‍⚖️ "\U0001f9d1\U0001f3fc\U0000200d\U00002696", # 🧑🏼‍⚖ "\U0001f9d1\U0001f3fd\U0000200d\U00002696\U0000fe0f", # 🧑🏽‍⚖️ "\U0001f9d1\U0001f3fd\U0000200d\U00002696", # 🧑🏽‍⚖ "\U0001f54b", # 🕋 "\U0001f998", # 🦘 "\U0001f511", # 🔑 "\U00002328\U0000fe0f", # ⌨️ "\U00002328", # ⌨ "\U00000023\U0000fe0f\U000020e3", # #️⃣ "\U00000023\U000020e3", # #⃣ "\U0000002a\U0000fe0f\U000020e3", # *️⃣ "\U0000002a\U000020e3", # *⃣ "\U00000030\U0000fe0f\U000020e3", # 0️⃣ "\U00000030\U000020e3", # 0⃣ "\U00000031\U0000fe0f\U000020e3", # 1️⃣ "\U00000031\U000020e3", # 1⃣ "\U0001f51f", # 🔟 "\U00000032\U0000fe0f\U000020e3", # 2️⃣ "\U00000032\U000020e3", # 2⃣ "\U00000033\U0000fe0f\U000020e3", # 3️⃣ "\U00000033\U000020e3", # 3⃣ "\U00000034\U0000fe0f\U000020e3", # 4️⃣ "\U00000034\U000020e3", # 4⃣ "\U00000035\U0000fe0f\U000020e3", # 5️⃣ "\U00000035\U000020e3", # 5⃣ "\U00000036\U0000fe0f\U000020e3", # 6️⃣ "\U00000036\U000020e3", # 6⃣ "\U00000037\U0000fe0f\U000020e3", # 7️⃣ "\U00000037\U000020e3", # 7⃣ "\U00000038\U0000fe0f\U000020e3", # 8️⃣ "\U00000038\U000020e3", # 8⃣ "\U00000039\U0000fe0f\U000020e3", # 9️⃣ "\U00000039\U000020e3", # 9⃣ "\U0001faaf", # 🪯 "\U0001f6f4", # 🛴 "\U0001f458", # 👘 "\U0001f48f", # 💏 "\U0001f48f\U0001f3ff", # 💏🏿 "\U0001f48f\U0001f3fb", # 💏🏻 "\U0001f468\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468", # 👨‍❤️‍💋‍👨 "\U0001f468\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468", # 👨‍❤‍💋‍👨 "\U0001f468\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3ff", # 👨🏿‍❤️‍💋‍👨🏿 "\U0001f468\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3ff", # 👨🏿‍❤‍💋‍👨🏿 "\U0001f468\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fb", # 👨🏿‍❤️‍💋‍👨🏻 "\U0001f468\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fb", # 👨🏿‍❤‍💋‍👨🏻 "\U0001f468\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fe", # 👨🏿‍❤️‍💋‍👨🏾 "\U0001f468\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fe", # 👨🏿‍❤‍💋‍👨🏾 "\U0001f468\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fc", # 👨🏿‍❤️‍💋‍👨🏼 "\U0001f468\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fc", # 👨🏿‍❤‍💋‍👨🏼 "\U0001f468\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fd", # 👨🏿‍❤️‍💋‍👨🏽 "\U0001f468\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fd", # 👨🏿‍❤‍💋‍👨🏽 "\U0001f468\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fb", # 👨🏻‍❤️‍💋‍👨🏻 "\U0001f468\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fb", # 👨🏻‍❤‍💋‍👨🏻 "\U0001f468\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3ff", # 👨🏻‍❤️‍💋‍👨🏿 "\U0001f468\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3ff", # 👨🏻‍❤‍💋‍👨🏿 "\U0001f468\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fe", # 👨🏻‍❤️‍💋‍👨🏾 "\U0001f468\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fe", # 👨🏻‍❤‍💋‍👨🏾 "\U0001f468\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fc", # 👨🏻‍❤️‍💋‍👨🏼 "\U0001f468\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fc", # 👨🏻‍❤‍💋‍👨🏼 "\U0001f468\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fd", # 👨🏻‍❤️‍💋‍👨🏽 "\U0001f468\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fd", # 👨🏻‍❤‍💋‍👨🏽 "\U0001f468\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fe", # 👨🏾‍❤️‍💋‍👨🏾 "\U0001f468\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fe", # 👨🏾‍❤‍💋‍👨🏾 "\U0001f468\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3ff", # 👨🏾‍❤️‍💋‍👨🏿 "\U0001f468\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3ff", # 👨🏾‍❤‍💋‍👨🏿 "\U0001f468\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fb", # 👨🏾‍❤️‍💋‍👨🏻 "\U0001f468\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fb", # 👨🏾‍❤‍💋‍👨🏻 "\U0001f468\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fc", # 👨🏾‍❤️‍💋‍👨🏼 "\U0001f468\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fc", # 👨🏾‍❤‍💋‍👨🏼 "\U0001f468\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fd", # 👨🏾‍❤️‍💋‍👨🏽 "\U0001f468\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fd", # 👨🏾‍❤‍💋‍👨🏽 "\U0001f468\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fc", # 👨🏼‍❤️‍💋‍👨🏼 "\U0001f468\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fc", # 👨🏼‍❤‍💋‍👨🏼 "\U0001f468\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3ff", # 👨🏼‍❤️‍💋‍👨🏿 "\U0001f468\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3ff", # 👨🏼‍❤‍💋‍👨🏿 "\U0001f468\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fb", # 👨🏼‍❤️‍💋‍👨🏻 "\U0001f468\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fb", # 👨🏼‍❤‍💋‍👨🏻 "\U0001f468\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fe", # 👨🏼‍❤️‍💋‍👨🏾 "\U0001f468\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fe", # 👨🏼‍❤‍💋‍👨🏾 "\U0001f468\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fd", # 👨🏼‍❤️‍💋‍👨🏽 "\U0001f468\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fd", # 👨🏼‍❤‍💋‍👨🏽 "\U0001f468\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fd", # 👨🏽‍❤️‍💋‍👨🏽 "\U0001f468\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fd", # 👨🏽‍❤‍💋‍👨🏽 "\U0001f468\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3ff", # 👨🏽‍❤️‍💋‍👨🏿 "\U0001f468\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3ff", # 👨🏽‍❤‍💋‍👨🏿 "\U0001f468\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fb", # 👨🏽‍❤️‍💋‍👨🏻 "\U0001f468\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fb", # 👨🏽‍❤‍💋‍👨🏻 "\U0001f468\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fe", # 👨🏽‍❤️‍💋‍👨🏾 "\U0001f468\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fe", # 👨🏽‍❤‍💋‍👨🏾 "\U0001f468\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fc", # 👨🏽‍❤️‍💋‍👨🏼 "\U0001f468\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fc", # 👨🏽‍❤‍💋‍👨🏼 "\U0001f48b", # 💋 "\U0001f48f\U0001f3fe", # 💏🏾 "\U0001f48f\U0001f3fc", # 💏🏼 "\U0001f48f\U0001f3fd", # 💏🏽 "\U0001f9d1\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fb", # 🧑🏿‍❤️‍💋‍🧑🏻 "\U0001f9d1\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fb", # 🧑🏿‍❤‍💋‍🧑🏻 "\U0001f9d1\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fe", # 🧑🏿‍❤️‍💋‍🧑🏾 "\U0001f9d1\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fe", # 🧑🏿‍❤‍💋‍🧑🏾 "\U0001f9d1\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fc", # 🧑🏿‍❤️‍💋‍🧑🏼 "\U0001f9d1\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fc", # 🧑🏿‍❤‍💋‍🧑🏼 "\U0001f9d1\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fd", # 🧑🏿‍❤️‍💋‍🧑🏽 "\U0001f9d1\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fd", # 🧑🏿‍❤‍💋‍🧑🏽 "\U0001f9d1\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3ff", # 🧑🏻‍❤️‍💋‍🧑🏿 "\U0001f9d1\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3ff", # 🧑🏻‍❤‍💋‍🧑🏿 "\U0001f9d1\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fe", # 🧑🏻‍❤️‍💋‍🧑🏾 "\U0001f9d1\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fe", # 🧑🏻‍❤‍💋‍🧑🏾 "\U0001f9d1\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fc", # 🧑🏻‍❤️‍💋‍🧑🏼 "\U0001f9d1\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fc", # 🧑🏻‍❤‍💋‍🧑🏼 "\U0001f9d1\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fd", # 🧑🏻‍❤️‍💋‍🧑🏽 "\U0001f9d1\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fd", # 🧑🏻‍❤‍💋‍🧑🏽 "\U0001f9d1\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3ff", # 🧑🏾‍❤️‍💋‍🧑🏿 "\U0001f9d1\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3ff", # 🧑🏾‍❤‍💋‍🧑🏿 "\U0001f9d1\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fb", # 🧑🏾‍❤️‍💋‍🧑🏻 "\U0001f9d1\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fb", # 🧑🏾‍❤‍💋‍🧑🏻 "\U0001f9d1\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fc", # 🧑🏾‍❤️‍💋‍🧑🏼 "\U0001f9d1\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fc", # 🧑🏾‍❤‍💋‍🧑🏼 "\U0001f9d1\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fd", # 🧑🏾‍❤️‍💋‍🧑🏽 "\U0001f9d1\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fd", # 🧑🏾‍❤‍💋‍🧑🏽 "\U0001f9d1\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3ff", # 🧑🏼‍❤️‍💋‍🧑🏿 "\U0001f9d1\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3ff", # 🧑🏼‍❤‍💋‍🧑🏿 "\U0001f9d1\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fb", # 🧑🏼‍❤️‍💋‍🧑🏻 "\U0001f9d1\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fb", # 🧑🏼‍❤‍💋‍🧑🏻 "\U0001f9d1\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fe", # 🧑🏼‍❤️‍💋‍🧑🏾 "\U0001f9d1\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fe", # 🧑🏼‍❤‍💋‍🧑🏾 "\U0001f9d1\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fd", # 🧑🏼‍❤️‍💋‍🧑🏽 "\U0001f9d1\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fd", # 🧑🏼‍❤‍💋‍🧑🏽 "\U0001f9d1\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3ff", # 🧑🏽‍❤️‍💋‍🧑🏿 "\U0001f9d1\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3ff", # 🧑🏽‍❤‍💋‍🧑🏿 "\U0001f9d1\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fb", # 🧑🏽‍❤️‍💋‍🧑🏻 "\U0001f9d1\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fb", # 🧑🏽‍❤‍💋‍🧑🏻 "\U0001f9d1\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fe", # 🧑🏽‍❤️‍💋‍🧑🏾 "\U0001f9d1\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fe", # 🧑🏽‍❤‍💋‍🧑🏾 "\U0001f9d1\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fc", # 🧑🏽‍❤️‍💋‍🧑🏼 "\U0001f9d1\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f9d1\U0001f3fc", # 🧑🏽‍❤‍💋‍🧑🏼 "\U0001f469\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468", # 👩‍❤️‍💋‍👨 "\U0001f469\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468", # 👩‍❤‍💋‍👨 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3ff", # 👩🏿‍❤️‍💋‍👨🏿 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3ff", # 👩🏿‍❤‍💋‍👨🏿 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fb", # 👩🏿‍❤️‍💋‍👨🏻 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fb", # 👩🏿‍❤‍💋‍👨🏻 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fe", # 👩🏿‍❤️‍💋‍👨🏾 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fe", # 👩🏿‍❤‍💋‍👨🏾 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fc", # 👩🏿‍❤️‍💋‍👨🏼 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fc", # 👩🏿‍❤‍💋‍👨🏼 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fd", # 👩🏿‍❤️‍💋‍👨🏽 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fd", # 👩🏿‍❤‍💋‍👨🏽 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fb", # 👩🏻‍❤️‍💋‍👨🏻 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fb", # 👩🏻‍❤‍💋‍👨🏻 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3ff", # 👩🏻‍❤️‍💋‍👨🏿 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3ff", # 👩🏻‍❤‍💋‍👨🏿 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fe", # 👩🏻‍❤️‍💋‍👨🏾 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fe", # 👩🏻‍❤‍💋‍👨🏾 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fc", # 👩🏻‍❤️‍💋‍👨🏼 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fc", # 👩🏻‍❤‍💋‍👨🏼 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fd", # 👩🏻‍❤️‍💋‍👨🏽 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fd", # 👩🏻‍❤‍💋‍👨🏽 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fe", # 👩🏾‍❤️‍💋‍👨🏾 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fe", # 👩🏾‍❤‍💋‍👨🏾 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3ff", # 👩🏾‍❤️‍💋‍👨🏿 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3ff", # 👩🏾‍❤‍💋‍👨🏿 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fb", # 👩🏾‍❤️‍💋‍👨🏻 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fb", # 👩🏾‍❤‍💋‍👨🏻 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fc", # 👩🏾‍❤️‍💋‍👨🏼 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fc", # 👩🏾‍❤‍💋‍👨🏼 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fd", # 👩🏾‍❤️‍💋‍👨🏽 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fd", # 👩🏾‍❤‍💋‍👨🏽 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fc", # 👩🏼‍❤️‍💋‍👨🏼 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fc", # 👩🏼‍❤‍💋‍👨🏼 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3ff", # 👩🏼‍❤️‍💋‍👨🏿 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3ff", # 👩🏼‍❤‍💋‍👨🏿 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fb", # 👩🏼‍❤️‍💋‍👨🏻 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fb", # 👩🏼‍❤‍💋‍👨🏻 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fe", # 👩🏼‍❤️‍💋‍👨🏾 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fe", # 👩🏼‍❤‍💋‍👨🏾 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fd", # 👩🏼‍❤️‍💋‍👨🏽 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fd", # 👩🏼‍❤‍💋‍👨🏽 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fd", # 👩🏽‍❤️‍💋‍👨🏽 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fd", # 👩🏽‍❤‍💋‍👨🏽 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3ff", # 👩🏽‍❤️‍💋‍👨🏿 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3ff", # 👩🏽‍❤‍💋‍👨🏿 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fb", # 👩🏽‍❤️‍💋‍👨🏻 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fb", # 👩🏽‍❤‍💋‍👨🏻 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fe", # 👩🏽‍❤️‍💋‍👨🏾 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fe", # 👩🏽‍❤‍💋‍👨🏾 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fc", # 👩🏽‍❤️‍💋‍👨🏼 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f468\U0001f3fc", # 👩🏽‍❤‍💋‍👨🏼 "\U0001f469\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469", # 👩‍❤️‍💋‍👩 "\U0001f469\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469", # 👩‍❤‍💋‍👩 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3ff", # 👩🏿‍❤️‍💋‍👩🏿 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3ff", # 👩🏿‍❤‍💋‍👩🏿 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fb", # 👩🏿‍❤️‍💋‍👩🏻 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fb", # 👩🏿‍❤‍💋‍👩🏻 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fe", # 👩🏿‍❤️‍💋‍👩🏾 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fe", # 👩🏿‍❤‍💋‍👩🏾 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fc", # 👩🏿‍❤️‍💋‍👩🏼 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fc", # 👩🏿‍❤‍💋‍👩🏼 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fd", # 👩🏿‍❤️‍💋‍👩🏽 "\U0001f469\U0001f3ff\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fd", # 👩🏿‍❤‍💋‍👩🏽 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fb", # 👩🏻‍❤️‍💋‍👩🏻 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fb", # 👩🏻‍❤‍💋‍👩🏻 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3ff", # 👩🏻‍❤️‍💋‍👩🏿 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3ff", # 👩🏻‍❤‍💋‍👩🏿 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fe", # 👩🏻‍❤️‍💋‍👩🏾 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fe", # 👩🏻‍❤‍💋‍👩🏾 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fc", # 👩🏻‍❤️‍💋‍👩🏼 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fc", # 👩🏻‍❤‍💋‍👩🏼 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fd", # 👩🏻‍❤️‍💋‍👩🏽 "\U0001f469\U0001f3fb\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fd", # 👩🏻‍❤‍💋‍👩🏽 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fe", # 👩🏾‍❤️‍💋‍👩🏾 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fe", # 👩🏾‍❤‍💋‍👩🏾 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3ff", # 👩🏾‍❤️‍💋‍👩🏿 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3ff", # 👩🏾‍❤‍💋‍👩🏿 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fb", # 👩🏾‍❤️‍💋‍👩🏻 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fb", # 👩🏾‍❤‍💋‍👩🏻 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fc", # 👩🏾‍❤️‍💋‍👩🏼 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fc", # 👩🏾‍❤‍💋‍👩🏼 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fd", # 👩🏾‍❤️‍💋‍👩🏽 "\U0001f469\U0001f3fe\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fd", # 👩🏾‍❤‍💋‍👩🏽 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fc", # 👩🏼‍❤️‍💋‍👩🏼 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fc", # 👩🏼‍❤‍💋‍👩🏼 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3ff", # 👩🏼‍❤️‍💋‍👩🏿 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3ff", # 👩🏼‍❤‍💋‍👩🏿 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fb", # 👩🏼‍❤️‍💋‍👩🏻 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fb", # 👩🏼‍❤‍💋‍👩🏻 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fe", # 👩🏼‍❤️‍💋‍👩🏾 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fe", # 👩🏼‍❤‍💋‍👩🏾 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fd", # 👩🏼‍❤️‍💋‍👩🏽 "\U0001f469\U0001f3fc\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fd", # 👩🏼‍❤‍💋‍👩🏽 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fd", # 👩🏽‍❤️‍💋‍👩🏽 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fd", # 👩🏽‍❤‍💋‍👩🏽 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3ff", # 👩🏽‍❤️‍💋‍👩🏿 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3ff", # 👩🏽‍❤‍💋‍👩🏿 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fb", # 👩🏽‍❤️‍💋‍👩🏻 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fb", # 👩🏽‍❤‍💋‍👩🏻 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fe", # 👩🏽‍❤️‍💋‍👩🏾 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fe", # 👩🏽‍❤‍💋‍👩🏾 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000fe0f\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fc", # 👩🏽‍❤️‍💋‍👩🏼 "\U0001f469\U0001f3fd\U0000200d\U00002764\U0000200d\U0001f48b\U0000200d\U0001f469\U0001f3fc", # 👩🏽‍❤‍💋‍👩🏼 "\U0001f63d", # 😽 "\U0001f617", # 😗 "\U0001f61a", # 😚 "\U0001f619", # 😙 "\U0001f52a", # 🔪 "\U0001fa81", # 🪁 "\U0001f95d", # 🥝 "\U0001faa2", # 🪢 "\U0001f428", # 🐨 "\U0001f97c", # 🥼 "\U0001f3f7\U0000fe0f", # 🏷️ "\U0001f3f7", # 🏷 "\U0001f94d", # 🥍 "\U0001fa9c", # 🪜 "\U0001f41e", # 🐞 "\U0001f4bb", # 💻 "\U0001f537", # 🔷 "\U0001f536", # 🔶 "\U0001f317", # 🌗 "\U0001f31c", # 🌜 "\U000023ee\U0000fe0f", # ⏮️ "\U000023ee", # ⏮ "\U0000271d\U0000fe0f", # ✝️ "\U0000271d", # ✝ "\U0001f343", # 🍃 "\U0001fabe", # 🪾 "\U0001f96c", # 🥬 "\U0001f4d2", # 📒 "\U0001f91b", # 🤛 "\U0001f91b\U0001f3ff", # 🤛🏿 "\U0001f91b\U0001f3fb", # 🤛🏻 "\U0001f91b\U0001f3fe", # 🤛🏾 "\U0001f91b\U0001f3fc", # 🤛🏼 "\U0001f91b\U0001f3fd", # 🤛🏽 "\U00002194\U0000fe0f", # ↔️ "\U00002194", # ↔ "\U00002b05\U0000fe0f", # ⬅️ "\U00002b05", # ⬅ "\U000021aa\U0000fe0f", # ↪️ "\U000021aa", # ↪ "\U0001f6c5", # 🛅 "\U0001f5e8\U0000fe0f", # 🗨️ "\U0001f5e8", # 🗨 "\U0001faf2", # 🫲 "\U0001faf2\U0001f3ff", # 🫲🏿 "\U0001faf2\U0001f3fb", # 🫲🏻 "\U0001faf2\U0001f3fe", # 🫲🏾 "\U0001faf2\U0001f3fc", # 🫲🏼 "\U0001faf2\U0001f3fd", # 🫲🏽 "\U0001faf7", # 🫷 "\U0001faf7\U0001f3ff", # 🫷🏿 "\U0001faf7\U0001f3fb", # 🫷🏻 "\U0001faf7\U0001f3fe", # 🫷🏾 "\U0001faf7\U0001f3fc", # 🫷🏼 "\U0001faf7\U0001f3fd", # 🫷🏽 "\U0001f9b5", # 🦵 "\U0001f9b5\U0001f3ff", # 🦵🏿 "\U0001f9b5\U0001f3fb", # 🦵🏻 "\U0001f9b5\U0001f3fe", # 🦵🏾 "\U0001f9b5\U0001f3fc", # 🦵🏼 "\U0001f9b5\U0001f3fd", # 🦵🏽 "\U0001f34b", # 🍋 "\U0001f406", # 🐆 "\U0001f39a\U0000fe0f", # 🎚️ "\U0001f39a", # 🎚 "\U0001fa75", # 🩵 "\U0001f4a1", # 💡 "\U0001f688", # 🚈 "\U0001f3fb", # 🏻 "\U0001f34b\U0000200d\U0001f7e9", # 🍋‍🟩 "\U0001f517", # 🔗 "\U0001f587\U0000fe0f", # 🖇️ "\U0001f587", # 🖇 "\U0001f981", # 🦁 "\U0001f484", # 💄 "\U0001f6ae", # 🚮 "\U0001f98e", # 🦎 "\U0001f999", # 🦙 "\U0001f99e", # 🦞 "\U0001f512", # 🔒 "\U0001f510", # 🔐 "\U0001f50f", # 🔏 "\U0001f682", # 🚂 "\U0001f36d", # 🍭 "\U0001fa98", # 🪘 "\U0001f9f4", # 🧴 "\U0001fab7", # 🪷 "\U0001f62d", # 😭 "\U0001f4e2", # 📢 "\U0001f91f", # 🤟 "\U0001f91f\U0001f3ff", # 🤟🏿 "\U0001f91f\U0001f3fb", # 🤟🏻 "\U0001f91f\U0001f3fe", # 🤟🏾 "\U0001f91f\U0001f3fc", # 🤟🏼 "\U0001f91f\U0001f3fd", # 🤟🏽 "\U0001f3e9", # 🏩 "\U0001f48c", # 💌 "\U0001faab", # 🪫 "\U0001f9f3", # 🧳 "\U0001fac1", # 🫁 "\U0001f925", # 🤥 "\U0001f9d9", # 🧙 "\U0001f9d9\U0001f3ff", # 🧙🏿 "\U0001f9d9\U0001f3fb", # 🧙🏻 "\U0001f9d9\U0001f3fe", # 🧙🏾 "\U0001f9d9\U0001f3fc", # 🧙🏼 "\U0001f9d9\U0001f3fd", # 🧙🏽 "\U0001fa84", # 🪄 "\U0001f9f2", # 🧲 "\U0001f50d", # 🔍 "\U0001f50e", # 🔎 "\U0001f004", # 🀄 "\U00002642\U0000fe0f", # ♂️ "\U00002642", # ♂ "\U0001f9a3", # 🦣 "\U0001f468", # 👨 "\U0001f468\U0000200d\U0001f3a8", # 👨‍🎨 "\U0001f468\U0001f3ff\U0000200d\U0001f3a8", # 👨🏿‍🎨 "\U0001f468\U0001f3fb\U0000200d\U0001f3a8", # 👨🏻‍🎨 "\U0001f468\U0001f3fe\U0000200d\U0001f3a8", # 👨🏾‍🎨 "\U0001f468\U0001f3fc\U0000200d\U0001f3a8", # 👨🏼‍🎨 "\U0001f468\U0001f3fd\U0000200d\U0001f3a8", # 👨🏽‍🎨 "\U0001f468\U0000200d\U0001f680", # 👨‍🚀 "\U0001f468\U0001f3ff\U0000200d\U0001f680", # 👨🏿‍🚀 "\U0001f468\U0001f3fb\U0000200d\U0001f680", # 👨🏻‍🚀 "\U0001f468\U0001f3fe\U0000200d\U0001f680", # 👨🏾‍🚀 "\U0001f468\U0001f3fc\U0000200d\U0001f680", # 👨🏼‍🚀 "\U0001f468\U0001f3fd\U0000200d\U0001f680", # 👨🏽‍🚀 "\U0001f468\U0000200d\U0001f9b2", # 👨‍🦲 "\U0001f9d4\U0000200d\U00002642\U0000fe0f", # 🧔‍♂️ "\U0001f9d4\U0000200d\U00002642", # 🧔‍♂ "\U0001f6b4\U0000200d\U00002642\U0000fe0f", # 🚴‍♂️ "\U0001f6b4\U0000200d\U00002642", # 🚴‍♂ "\U0001f6b4\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🚴🏿‍♂️ "\U0001f6b4\U0001f3ff\U0000200d\U00002642", # 🚴🏿‍♂ "\U0001f6b4\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🚴🏻‍♂️ "\U0001f6b4\U0001f3fb\U0000200d\U00002642", # 🚴🏻‍♂ "\U0001f6b4\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🚴🏾‍♂️ "\U0001f6b4\U0001f3fe\U0000200d\U00002642", # 🚴🏾‍♂ "\U0001f6b4\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🚴🏼‍♂️ "\U0001f6b4\U0001f3fc\U0000200d\U00002642", # 🚴🏼‍♂ "\U0001f6b4\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🚴🏽‍♂️ "\U0001f6b4\U0001f3fd\U0000200d\U00002642", # 🚴🏽‍♂ "\U0001f471\U0000200d\U00002642\U0000fe0f", # 👱‍♂️ "\U0001f471\U0000200d\U00002642", # 👱‍♂ "\U000026f9\U0000fe0f\U0000200d\U00002642\U0000fe0f", # ⛹️‍♂️ "\U000026f9\U0000200d\U00002642\U0000fe0f", # ⛹‍♂️ "\U000026f9\U0000fe0f\U0000200d\U00002642", # ⛹️‍♂ "\U000026f9\U0000200d\U00002642", # ⛹‍♂ "\U000026f9\U0001f3ff\U0000200d\U00002642\U0000fe0f", # ⛹🏿‍♂️ "\U000026f9\U0001f3ff\U0000200d\U00002642", # ⛹🏿‍♂ "\U000026f9\U0001f3fb\U0000200d\U00002642\U0000fe0f", # ⛹🏻‍♂️ "\U000026f9\U0001f3fb\U0000200d\U00002642", # ⛹🏻‍♂ "\U000026f9\U0001f3fe\U0000200d\U00002642\U0000fe0f", # ⛹🏾‍♂️ "\U000026f9\U0001f3fe\U0000200d\U00002642", # ⛹🏾‍♂ "\U000026f9\U0001f3fc\U0000200d\U00002642\U0000fe0f", # ⛹🏼‍♂️ "\U000026f9\U0001f3fc\U0000200d\U00002642", # ⛹🏼‍♂ "\U000026f9\U0001f3fd\U0000200d\U00002642\U0000fe0f", # ⛹🏽‍♂️ "\U000026f9\U0001f3fd\U0000200d\U00002642", # ⛹🏽‍♂ "\U0001f647\U0000200d\U00002642\U0000fe0f", # 🙇‍♂️ "\U0001f647\U0000200d\U00002642", # 🙇‍♂ "\U0001f647\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🙇🏿‍♂️ "\U0001f647\U0001f3ff\U0000200d\U00002642", # 🙇🏿‍♂ "\U0001f647\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🙇🏻‍♂️ "\U0001f647\U0001f3fb\U0000200d\U00002642", # 🙇🏻‍♂ "\U0001f647\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🙇🏾‍♂️ "\U0001f647\U0001f3fe\U0000200d\U00002642", # 🙇🏾‍♂ "\U0001f647\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🙇🏼‍♂️ "\U0001f647\U0001f3fc\U0000200d\U00002642", # 🙇🏼‍♂ "\U0001f647\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🙇🏽‍♂️ "\U0001f647\U0001f3fd\U0000200d\U00002642", # 🙇🏽‍♂ "\U0001f938\U0000200d\U00002642\U0000fe0f", # 🤸‍♂️ "\U0001f938\U0000200d\U00002642", # 🤸‍♂ "\U0001f938\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🤸🏿‍♂️ "\U0001f938\U0001f3ff\U0000200d\U00002642", # 🤸🏿‍♂ "\U0001f938\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🤸🏻‍♂️ "\U0001f938\U0001f3fb\U0000200d\U00002642", # 🤸🏻‍♂ "\U0001f938\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🤸🏾‍♂️ "\U0001f938\U0001f3fe\U0000200d\U00002642", # 🤸🏾‍♂ "\U0001f938\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🤸🏼‍♂️ "\U0001f938\U0001f3fc\U0000200d\U00002642", # 🤸🏼‍♂ "\U0001f938\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🤸🏽‍♂️ "\U0001f938\U0001f3fd\U0000200d\U00002642", # 🤸🏽‍♂ "\U0001f9d7\U0000200d\U00002642\U0000fe0f", # 🧗‍♂️ "\U0001f9d7\U0000200d\U00002642", # 🧗‍♂ "\U0001f9d7\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🧗🏿‍♂️ "\U0001f9d7\U0001f3ff\U0000200d\U00002642", # 🧗🏿‍♂ "\U0001f9d7\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🧗🏻‍♂️ "\U0001f9d7\U0001f3fb\U0000200d\U00002642", # 🧗🏻‍♂ "\U0001f9d7\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🧗🏾‍♂️ "\U0001f9d7\U0001f3fe\U0000200d\U00002642", # 🧗🏾‍♂ "\U0001f9d7\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🧗🏼‍♂️ "\U0001f9d7\U0001f3fc\U0000200d\U00002642", # 🧗🏼‍♂ "\U0001f9d7\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🧗🏽‍♂️ "\U0001f9d7\U0001f3fd\U0000200d\U00002642", # 🧗🏽‍♂ "\U0001f477\U0000200d\U00002642\U0000fe0f", # 👷‍♂️ "\U0001f477\U0000200d\U00002642", # 👷‍♂ "\U0001f477\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 👷🏿‍♂️ "\U0001f477\U0001f3ff\U0000200d\U00002642", # 👷🏿‍♂ "\U0001f477\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 👷🏻‍♂️ "\U0001f477\U0001f3fb\U0000200d\U00002642", # 👷🏻‍♂ "\U0001f477\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 👷🏾‍♂️ "\U0001f477\U0001f3fe\U0000200d\U00002642", # 👷🏾‍♂ "\U0001f477\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 👷🏼‍♂️ "\U0001f477\U0001f3fc\U0000200d\U00002642", # 👷🏼‍♂ "\U0001f477\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 👷🏽‍♂️ "\U0001f477\U0001f3fd\U0000200d\U00002642", # 👷🏽‍♂ "\U0001f468\U0000200d\U0001f373", # 👨‍🍳 "\U0001f468\U0001f3ff\U0000200d\U0001f373", # 👨🏿‍🍳 "\U0001f468\U0001f3fb\U0000200d\U0001f373", # 👨🏻‍🍳 "\U0001f468\U0001f3fe\U0000200d\U0001f373", # 👨🏾‍🍳 "\U0001f468\U0001f3fc\U0000200d\U0001f373", # 👨🏼‍🍳 "\U0001f468\U0001f3fd\U0000200d\U0001f373", # 👨🏽‍🍳 "\U0001f468\U0000200d\U0001f9b1", # 👨‍🦱 "\U0001f57a", # 🕺 "\U0001f57a\U0001f3ff", # 🕺🏿 "\U0001f57a\U0001f3fb", # 🕺🏻 "\U0001f57a\U0001f3fe", # 🕺🏾 "\U0001f57a\U0001f3fc", # 🕺🏼 "\U0001f57a\U0001f3fd", # 🕺🏽 "\U0001f468\U0001f3ff", # 👨🏿 "\U0001f468\U0001f3ff\U0000200d\U0001f9b2", # 👨🏿‍🦲 "\U0001f9d4\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🧔🏿‍♂️ "\U0001f9d4\U0001f3ff\U0000200d\U00002642", # 🧔🏿‍♂ "\U0001f471\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 👱🏿‍♂️ "\U0001f471\U0001f3ff\U0000200d\U00002642", # 👱🏿‍♂ "\U0001f468\U0001f3ff\U0000200d\U0001f9b1", # 👨🏿‍🦱 "\U0001f468\U0001f3ff\U0000200d\U0001f9b0", # 👨🏿‍🦰 "\U0001f468\U0001f3ff\U0000200d\U0001f9b3", # 👨🏿‍🦳 "\U0001f575\U0000fe0f\U0000200d\U00002642\U0000fe0f", # 🕵️‍♂️ "\U0001f575\U0000200d\U00002642\U0000fe0f", # 🕵‍♂️ "\U0001f575\U0000fe0f\U0000200d\U00002642", # 🕵️‍♂ "\U0001f575\U0000200d\U00002642", # 🕵‍♂ "\U0001f575\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🕵🏿‍♂️ "\U0001f575\U0001f3ff\U0000200d\U00002642", # 🕵🏿‍♂ "\U0001f575\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🕵🏻‍♂️ "\U0001f575\U0001f3fb\U0000200d\U00002642", # 🕵🏻‍♂ "\U0001f575\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🕵🏾‍♂️ "\U0001f575\U0001f3fe\U0000200d\U00002642", # 🕵🏾‍♂ "\U0001f575\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🕵🏼‍♂️ "\U0001f575\U0001f3fc\U0000200d\U00002642", # 🕵🏼‍♂ "\U0001f575\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🕵🏽‍♂️ "\U0001f575\U0001f3fd\U0000200d\U00002642", # 🕵🏽‍♂ "\U0001f9dd\U0000200d\U00002642\U0000fe0f", # 🧝‍♂️ "\U0001f9dd\U0000200d\U00002642", # 🧝‍♂ "\U0001f9dd\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🧝🏿‍♂️ "\U0001f9dd\U0001f3ff\U0000200d\U00002642", # 🧝🏿‍♂ "\U0001f9dd\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🧝🏻‍♂️ "\U0001f9dd\U0001f3fb\U0000200d\U00002642", # 🧝🏻‍♂ "\U0001f9dd\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🧝🏾‍♂️ "\U0001f9dd\U0001f3fe\U0000200d\U00002642", # 🧝🏾‍♂ "\U0001f9dd\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🧝🏼‍♂️ "\U0001f9dd\U0001f3fc\U0000200d\U00002642", # 🧝🏼‍♂ "\U0001f9dd\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🧝🏽‍♂️ "\U0001f9dd\U0001f3fd\U0000200d\U00002642", # 🧝🏽‍♂ "\U0001f926\U0000200d\U00002642\U0000fe0f", # 🤦‍♂️ "\U0001f926\U0000200d\U00002642", # 🤦‍♂ "\U0001f926\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🤦🏿‍♂️ "\U0001f926\U0001f3ff\U0000200d\U00002642", # 🤦🏿‍♂ "\U0001f926\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🤦🏻‍♂️ "\U0001f926\U0001f3fb\U0000200d\U00002642", # 🤦🏻‍♂ "\U0001f926\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🤦🏾‍♂️ "\U0001f926\U0001f3fe\U0000200d\U00002642", # 🤦🏾‍♂ "\U0001f926\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🤦🏼‍♂️ "\U0001f926\U0001f3fc\U0000200d\U00002642", # 🤦🏼‍♂ "\U0001f926\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🤦🏽‍♂️ "\U0001f926\U0001f3fd\U0000200d\U00002642", # 🤦🏽‍♂ "\U0001f468\U0000200d\U0001f3ed", # 👨‍🏭 "\U0001f468\U0001f3ff\U0000200d\U0001f3ed", # 👨🏿‍🏭 "\U0001f468\U0001f3fb\U0000200d\U0001f3ed", # 👨🏻‍🏭 "\U0001f468\U0001f3fe\U0000200d\U0001f3ed", # 👨🏾‍🏭 "\U0001f468\U0001f3fc\U0000200d\U0001f3ed", # 👨🏼‍🏭 "\U0001f468\U0001f3fd\U0000200d\U0001f3ed", # 👨🏽‍🏭 "\U0001f9da\U0000200d\U00002642\U0000fe0f", # 🧚‍♂️ "\U0001f9da\U0000200d\U00002642", # 🧚‍♂ "\U0001f9da\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🧚🏿‍♂️ "\U0001f9da\U0001f3ff\U0000200d\U00002642", # 🧚🏿‍♂ "\U0001f9da\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🧚🏻‍♂️ "\U0001f9da\U0001f3fb\U0000200d\U00002642", # 🧚🏻‍♂ "\U0001f9da\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🧚🏾‍♂️ "\U0001f9da\U0001f3fe\U0000200d\U00002642", # 🧚🏾‍♂ "\U0001f9da\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🧚🏼‍♂️ "\U0001f9da\U0001f3fc\U0000200d\U00002642", # 🧚🏼‍♂ "\U0001f9da\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🧚🏽‍♂️ "\U0001f9da\U0001f3fd\U0000200d\U00002642", # 🧚🏽‍♂ "\U0001f468\U0000200d\U0001f33e", # 👨‍🌾 "\U0001f468\U0001f3ff\U0000200d\U0001f33e", # 👨🏿‍🌾 "\U0001f468\U0001f3fb\U0000200d\U0001f33e", # 👨🏻‍🌾 "\U0001f468\U0001f3fe\U0000200d\U0001f33e", # 👨🏾‍🌾 "\U0001f468\U0001f3fc\U0000200d\U0001f33e", # 👨🏼‍🌾 "\U0001f468\U0001f3fd\U0000200d\U0001f33e", # 👨🏽‍🌾 "\U0001f468\U0000200d\U0001f37c", # 👨‍🍼 "\U0001f468\U0001f3ff\U0000200d\U0001f37c", # 👨🏿‍🍼 "\U0001f468\U0001f3fb\U0000200d\U0001f37c", # 👨🏻‍🍼 "\U0001f468\U0001f3fe\U0000200d\U0001f37c", # 👨🏾‍🍼 "\U0001f468\U0001f3fc\U0000200d\U0001f37c", # 👨🏼‍🍼 "\U0001f468\U0001f3fd\U0000200d\U0001f37c", # 👨🏽‍🍼 "\U0001f468\U0000200d\U0001f692", # 👨‍🚒 "\U0001f468\U0001f3ff\U0000200d\U0001f692", # 👨🏿‍🚒 "\U0001f468\U0001f3fb\U0000200d\U0001f692", # 👨🏻‍🚒 "\U0001f468\U0001f3fe\U0000200d\U0001f692", # 👨🏾‍🚒 "\U0001f468\U0001f3fc\U0000200d\U0001f692", # 👨🏼‍🚒 "\U0001f468\U0001f3fd\U0000200d\U0001f692", # 👨🏽‍🚒 "\U0001f64d\U0000200d\U00002642\U0000fe0f", # 🙍‍♂️ "\U0001f64d\U0000200d\U00002642", # 🙍‍♂ "\U0001f64d\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🙍🏿‍♂️ "\U0001f64d\U0001f3ff\U0000200d\U00002642", # 🙍🏿‍♂ "\U0001f64d\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🙍🏻‍♂️ "\U0001f64d\U0001f3fb\U0000200d\U00002642", # 🙍🏻‍♂ "\U0001f64d\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🙍🏾‍♂️ "\U0001f64d\U0001f3fe\U0000200d\U00002642", # 🙍🏾‍♂ "\U0001f64d\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🙍🏼‍♂️ "\U0001f64d\U0001f3fc\U0000200d\U00002642", # 🙍🏼‍♂ "\U0001f64d\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🙍🏽‍♂️ "\U0001f64d\U0001f3fd\U0000200d\U00002642", # 🙍🏽‍♂ "\U0001f9de\U0000200d\U00002642\U0000fe0f", # 🧞‍♂️ "\U0001f9de\U0000200d\U00002642", # 🧞‍♂ "\U0001f645\U0000200d\U00002642\U0000fe0f", # 🙅‍♂️ "\U0001f645\U0000200d\U00002642", # 🙅‍♂ "\U0001f645\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🙅🏿‍♂️ "\U0001f645\U0001f3ff\U0000200d\U00002642", # 🙅🏿‍♂ "\U0001f645\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🙅🏻‍♂️ "\U0001f645\U0001f3fb\U0000200d\U00002642", # 🙅🏻‍♂ "\U0001f645\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🙅🏾‍♂️ "\U0001f645\U0001f3fe\U0000200d\U00002642", # 🙅🏾‍♂ "\U0001f645\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🙅🏼‍♂️ "\U0001f645\U0001f3fc\U0000200d\U00002642", # 🙅🏼‍♂ "\U0001f645\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🙅🏽‍♂️ "\U0001f645\U0001f3fd\U0000200d\U00002642", # 🙅🏽‍♂ "\U0001f646\U0000200d\U00002642\U0000fe0f", # 🙆‍♂️ "\U0001f646\U0000200d\U00002642", # 🙆‍♂ "\U0001f646\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🙆🏿‍♂️ "\U0001f646\U0001f3ff\U0000200d\U00002642", # 🙆🏿‍♂ "\U0001f646\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🙆🏻‍♂️ "\U0001f646\U0001f3fb\U0000200d\U00002642", # 🙆🏻‍♂ "\U0001f646\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🙆🏾‍♂️ "\U0001f646\U0001f3fe\U0000200d\U00002642", # 🙆🏾‍♂ "\U0001f646\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🙆🏼‍♂️ "\U0001f646\U0001f3fc\U0000200d\U00002642", # 🙆🏼‍♂ "\U0001f646\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🙆🏽‍♂️ "\U0001f646\U0001f3fd\U0000200d\U00002642", # 🙆🏽‍♂ "\U0001f487\U0000200d\U00002642\U0000fe0f", # 💇‍♂️ "\U0001f487\U0000200d\U00002642", # 💇‍♂ "\U0001f487\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 💇🏿‍♂️ "\U0001f487\U0001f3ff\U0000200d\U00002642", # 💇🏿‍♂ "\U0001f487\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 💇🏻‍♂️ "\U0001f487\U0001f3fb\U0000200d\U00002642", # 💇🏻‍♂ "\U0001f487\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 💇🏾‍♂️ "\U0001f487\U0001f3fe\U0000200d\U00002642", # 💇🏾‍♂ "\U0001f487\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 💇🏼‍♂️ "\U0001f487\U0001f3fc\U0000200d\U00002642", # 💇🏼‍♂ "\U0001f487\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 💇🏽‍♂️ "\U0001f487\U0001f3fd\U0000200d\U00002642", # 💇🏽‍♂ "\U0001f486\U0000200d\U00002642\U0000fe0f", # 💆‍♂️ "\U0001f486\U0000200d\U00002642", # 💆‍♂ "\U0001f486\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 💆🏿‍♂️ "\U0001f486\U0001f3ff\U0000200d\U00002642", # 💆🏿‍♂ "\U0001f486\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 💆🏻‍♂️ "\U0001f486\U0001f3fb\U0000200d\U00002642", # 💆🏻‍♂ "\U0001f486\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 💆🏾‍♂️ "\U0001f486\U0001f3fe\U0000200d\U00002642", # 💆🏾‍♂ "\U0001f486\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 💆🏼‍♂️ "\U0001f486\U0001f3fc\U0000200d\U00002642", # 💆🏼‍♂ "\U0001f486\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 💆🏽‍♂️ "\U0001f486\U0001f3fd\U0000200d\U00002642", # 💆🏽‍♂ "\U0001f3cc\U0000fe0f\U0000200d\U00002642\U0000fe0f", # 🏌️‍♂️ "\U0001f3cc\U0000200d\U00002642\U0000fe0f", # 🏌‍♂️ "\U0001f3cc\U0000fe0f\U0000200d\U00002642", # 🏌️‍♂ "\U0001f3cc\U0000200d\U00002642", # 🏌‍♂ "\U0001f3cc\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🏌🏿‍♂️ "\U0001f3cc\U0001f3ff\U0000200d\U00002642", # 🏌🏿‍♂ "\U0001f3cc\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🏌🏻‍♂️ "\U0001f3cc\U0001f3fb\U0000200d\U00002642", # 🏌🏻‍♂ "\U0001f3cc\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🏌🏾‍♂️ "\U0001f3cc\U0001f3fe\U0000200d\U00002642", # 🏌🏾‍♂ "\U0001f3cc\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🏌🏼‍♂️ "\U0001f3cc\U0001f3fc\U0000200d\U00002642", # 🏌🏼‍♂ "\U0001f3cc\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🏌🏽‍♂️ "\U0001f3cc\U0001f3fd\U0000200d\U00002642", # 🏌🏽‍♂ "\U0001f482\U0000200d\U00002642\U0000fe0f", # 💂‍♂️ "\U0001f482\U0000200d\U00002642", # 💂‍♂ "\U0001f482\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 💂🏿‍♂️ "\U0001f482\U0001f3ff\U0000200d\U00002642", # 💂🏿‍♂ "\U0001f482\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 💂🏻‍♂️ "\U0001f482\U0001f3fb\U0000200d\U00002642", # 💂🏻‍♂ "\U0001f482\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 💂🏾‍♂️ "\U0001f482\U0001f3fe\U0000200d\U00002642", # 💂🏾‍♂ "\U0001f482\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 💂🏼‍♂️ "\U0001f482\U0001f3fc\U0000200d\U00002642", # 💂🏼‍♂ "\U0001f482\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 💂🏽‍♂️ "\U0001f482\U0001f3fd\U0000200d\U00002642", # 💂🏽‍♂ "\U0001f468\U0000200d\U00002695\U0000fe0f", # 👨‍⚕️ "\U0001f468\U0000200d\U00002695", # 👨‍⚕ "\U0001f468\U0001f3ff\U0000200d\U00002695\U0000fe0f", # 👨🏿‍⚕️ "\U0001f468\U0001f3ff\U0000200d\U00002695", # 👨🏿‍⚕ "\U0001f468\U0001f3fb\U0000200d\U00002695\U0000fe0f", # 👨🏻‍⚕️ "\U0001f468\U0001f3fb\U0000200d\U00002695", # 👨🏻‍⚕ "\U0001f468\U0001f3fe\U0000200d\U00002695\U0000fe0f", # 👨🏾‍⚕️ "\U0001f468\U0001f3fe\U0000200d\U00002695", # 👨🏾‍⚕ "\U0001f468\U0001f3fc\U0000200d\U00002695\U0000fe0f", # 👨🏼‍⚕️ "\U0001f468\U0001f3fc\U0000200d\U00002695", # 👨🏼‍⚕ "\U0001f468\U0001f3fd\U0000200d\U00002695\U0000fe0f", # 👨🏽‍⚕️ "\U0001f468\U0001f3fd\U0000200d\U00002695", # 👨🏽‍⚕ "\U0001f9d8\U0000200d\U00002642\U0000fe0f", # 🧘‍♂️ "\U0001f9d8\U0000200d\U00002642", # 🧘‍♂ "\U0001f9d8\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🧘🏿‍♂️ "\U0001f9d8\U0001f3ff\U0000200d\U00002642", # 🧘🏿‍♂ "\U0001f9d8\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🧘🏻‍♂️ "\U0001f9d8\U0001f3fb\U0000200d\U00002642", # 🧘🏻‍♂ "\U0001f9d8\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🧘🏾‍♂️ "\U0001f9d8\U0001f3fe\U0000200d\U00002642", # 🧘🏾‍♂ "\U0001f9d8\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🧘🏼‍♂️ "\U0001f9d8\U0001f3fc\U0000200d\U00002642", # 🧘🏼‍♂ "\U0001f9d8\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🧘🏽‍♂️ "\U0001f9d8\U0001f3fd\U0000200d\U00002642", # 🧘🏽‍♂ "\U0001f468\U0000200d\U0001f9bd", # 👨‍🦽 "\U0001f468\U0001f3ff\U0000200d\U0001f9bd", # 👨🏿‍🦽 "\U0001f468\U0000200d\U0001f9bd\U0000200d\U000027a1\U0000fe0f", # 👨‍🦽‍➡️ "\U0001f468\U0000200d\U0001f9bd\U0000200d\U000027a1", # 👨‍🦽‍➡ "\U0001f468\U0001f3ff\U0000200d\U0001f9bd\U0000200d\U000027a1\U0000fe0f", # 👨🏿‍🦽‍➡️ "\U0001f468\U0001f3ff\U0000200d\U0001f9bd\U0000200d\U000027a1", # 👨🏿‍🦽‍➡ "\U0001f468\U0001f3fb\U0000200d\U0001f9bd\U0000200d\U000027a1\U0000fe0f", # 👨🏻‍🦽‍➡️ "\U0001f468\U0001f3fb\U0000200d\U0001f9bd\U0000200d\U000027a1", # 👨🏻‍🦽‍➡ "\U0001f468\U0001f3fe\U0000200d\U0001f9bd\U0000200d\U000027a1\U0000fe0f", # 👨🏾‍🦽‍➡️ "\U0001f468\U0001f3fe\U0000200d\U0001f9bd\U0000200d\U000027a1", # 👨🏾‍🦽‍➡ "\U0001f468\U0001f3fc\U0000200d\U0001f9bd\U0000200d\U000027a1\U0000fe0f", # 👨🏼‍🦽‍➡️ "\U0001f468\U0001f3fc\U0000200d\U0001f9bd\U0000200d\U000027a1", # 👨🏼‍🦽‍➡ "\U0001f468\U0001f3fd\U0000200d\U0001f9bd\U0000200d\U000027a1\U0000fe0f", # 👨🏽‍🦽‍➡️ "\U0001f468\U0001f3fd\U0000200d\U0001f9bd\U0000200d\U000027a1", # 👨🏽‍🦽‍➡ "\U0001f468\U0001f3fb\U0000200d\U0001f9bd", # 👨🏻‍🦽 "\U0001f468\U0001f3fe\U0000200d\U0001f9bd", # 👨🏾‍🦽 "\U0001f468\U0001f3fc\U0000200d\U0001f9bd", # 👨🏼‍🦽 "\U0001f468\U0001f3fd\U0000200d\U0001f9bd", # 👨🏽‍🦽 "\U0001f468\U0000200d\U0001f9bc", # 👨‍🦼 "\U0001f468\U0001f3ff\U0000200d\U0001f9bc", # 👨🏿‍🦼 "\U0001f468\U0000200d\U0001f9bc\U0000200d\U000027a1\U0000fe0f", # 👨‍🦼‍➡️ "\U0001f468\U0000200d\U0001f9bc\U0000200d\U000027a1", # 👨‍🦼‍➡ "\U0001f468\U0001f3ff\U0000200d\U0001f9bc\U0000200d\U000027a1\U0000fe0f", # 👨🏿‍🦼‍➡️ "\U0001f468\U0001f3ff\U0000200d\U0001f9bc\U0000200d\U000027a1", # 👨🏿‍🦼‍➡ "\U0001f468\U0001f3fb\U0000200d\U0001f9bc\U0000200d\U000027a1\U0000fe0f", # 👨🏻‍🦼‍➡️ "\U0001f468\U0001f3fb\U0000200d\U0001f9bc\U0000200d\U000027a1", # 👨🏻‍🦼‍➡ "\U0001f468\U0001f3fe\U0000200d\U0001f9bc\U0000200d\U000027a1\U0000fe0f", # 👨🏾‍🦼‍➡️ "\U0001f468\U0001f3fe\U0000200d\U0001f9bc\U0000200d\U000027a1", # 👨🏾‍🦼‍➡ "\U0001f468\U0001f3fc\U0000200d\U0001f9bc\U0000200d\U000027a1\U0000fe0f", # 👨🏼‍🦼‍➡️ "\U0001f468\U0001f3fc\U0000200d\U0001f9bc\U0000200d\U000027a1", # 👨🏼‍🦼‍➡ "\U0001f468\U0001f3fd\U0000200d\U0001f9bc\U0000200d\U000027a1\U0000fe0f", # 👨🏽‍🦼‍➡️ "\U0001f468\U0001f3fd\U0000200d\U0001f9bc\U0000200d\U000027a1", # 👨🏽‍🦼‍➡ "\U0001f468\U0001f3fb\U0000200d\U0001f9bc", # 👨🏻‍🦼 "\U0001f468\U0001f3fe\U0000200d\U0001f9bc", # 👨🏾‍🦼 "\U0001f468\U0001f3fc\U0000200d\U0001f9bc", # 👨🏼‍🦼 "\U0001f468\U0001f3fd\U0000200d\U0001f9bc", # 👨🏽‍🦼 "\U0001f9d6\U0000200d\U00002642\U0000fe0f", # 🧖‍♂️ "\U0001f9d6\U0000200d\U00002642", # 🧖‍♂ "\U0001f9d6\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🧖🏿‍♂️ "\U0001f9d6\U0001f3ff\U0000200d\U00002642", # 🧖🏿‍♂ "\U0001f9d6\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🧖🏻‍♂️ "\U0001f9d6\U0001f3fb\U0000200d\U00002642", # 🧖🏻‍♂ "\U0001f9d6\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🧖🏾‍♂️ "\U0001f9d6\U0001f3fe\U0000200d\U00002642", # 🧖🏾‍♂ "\U0001f9d6\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🧖🏼‍♂️ "\U0001f9d6\U0001f3fc\U0000200d\U00002642", # 🧖🏼‍♂ "\U0001f9d6\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🧖🏽‍♂️ "\U0001f9d6\U0001f3fd\U0000200d\U00002642", # 🧖🏽‍♂ "\U0001f935\U0000200d\U00002642\U0000fe0f", # 🤵‍♂️ "\U0001f935\U0000200d\U00002642", # 🤵‍♂ "\U0001f935\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🤵🏿‍♂️ "\U0001f935\U0001f3ff\U0000200d\U00002642", # 🤵🏿‍♂ "\U0001f935\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🤵🏻‍♂️ "\U0001f935\U0001f3fb\U0000200d\U00002642", # 🤵🏻‍♂ "\U0001f935\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🤵🏾‍♂️ "\U0001f935\U0001f3fe\U0000200d\U00002642", # 🤵🏾‍♂ "\U0001f935\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🤵🏼‍♂️ "\U0001f935\U0001f3fc\U0000200d\U00002642", # 🤵🏼‍♂ "\U0001f935\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🤵🏽‍♂️ "\U0001f935\U0001f3fd\U0000200d\U00002642", # 🤵🏽‍♂ "\U0001f468\U0000200d\U00002696\U0000fe0f", # 👨‍⚖️ "\U0001f468\U0000200d\U00002696", # 👨‍⚖ "\U0001f468\U0001f3ff\U0000200d\U00002696\U0000fe0f", # 👨🏿‍⚖️ "\U0001f468\U0001f3ff\U0000200d\U00002696", # 👨🏿‍⚖ "\U0001f468\U0001f3fb\U0000200d\U00002696\U0000fe0f", # 👨🏻‍⚖️ "\U0001f468\U0001f3fb\U0000200d\U00002696", # 👨🏻‍⚖ "\U0001f468\U0001f3fe\U0000200d\U00002696\U0000fe0f", # 👨🏾‍⚖️ "\U0001f468\U0001f3fe\U0000200d\U00002696", # 👨🏾‍⚖ "\U0001f468\U0001f3fc\U0000200d\U00002696\U0000fe0f", # 👨🏼‍⚖️ "\U0001f468\U0001f3fc\U0000200d\U00002696", # 👨🏼‍⚖ "\U0001f468\U0001f3fd\U0000200d\U00002696\U0000fe0f", # 👨🏽‍⚖️ "\U0001f468\U0001f3fd\U0000200d\U00002696", # 👨🏽‍⚖ "\U0001f939\U0000200d\U00002642\U0000fe0f", # 🤹‍♂️ "\U0001f939\U0000200d\U00002642", # 🤹‍♂ "\U0001f939\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🤹🏿‍♂️ "\U0001f939\U0001f3ff\U0000200d\U00002642", # 🤹🏿‍♂ "\U0001f939\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🤹🏻‍♂️ "\U0001f939\U0001f3fb\U0000200d\U00002642", # 🤹🏻‍♂ "\U0001f939\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🤹🏾‍♂️ "\U0001f939\U0001f3fe\U0000200d\U00002642", # 🤹🏾‍♂ "\U0001f939\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🤹🏼‍♂️ "\U0001f939\U0001f3fc\U0000200d\U00002642", # 🤹🏼‍♂ "\U0001f939\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🤹🏽‍♂️ "\U0001f939\U0001f3fd\U0000200d\U00002642", # 🤹🏽‍♂ "\U0001f9ce\U0000200d\U00002642\U0000fe0f", # 🧎‍♂️ "\U0001f9ce\U0000200d\U00002642", # 🧎‍♂ "\U0001f9ce\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🧎🏿‍♂️ "\U0001f9ce\U0001f3ff\U0000200d\U00002642", # 🧎🏿‍♂ "\U0001f9ce\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🧎‍♂️‍➡️ "\U0001f9ce\U0000200d\U00002642\U0000200d\U000027a1\U0000fe0f", # 🧎‍♂‍➡️ "\U0001f9ce\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1", # 🧎‍♂️‍➡ "\U0001f9ce\U0000200d\U00002642\U0000200d\U000027a1", # 🧎‍♂‍➡ "\U0001f9ce\U0001f3ff\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🧎🏿‍♂️‍➡️ "\U0001f9ce\U0001f3ff\U0000200d\U00002642\U0000200d\U000027a1\U0000fe0f", # 🧎🏿‍♂‍➡️ "\U0001f9ce\U0001f3ff\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1", # 🧎🏿‍♂️‍➡ "\U0001f9ce\U0001f3ff\U0000200d\U00002642\U0000200d\U000027a1", # 🧎🏿‍♂‍➡ "\U0001f9ce\U0001f3fb\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🧎🏻‍♂️‍➡️ "\U0001f9ce\U0001f3fb\U0000200d\U00002642\U0000200d\U000027a1\U0000fe0f", # 🧎🏻‍♂‍➡️ "\U0001f9ce\U0001f3fb\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1", # 🧎🏻‍♂️‍➡ "\U0001f9ce\U0001f3fb\U0000200d\U00002642\U0000200d\U000027a1", # 🧎🏻‍♂‍➡ "\U0001f9ce\U0001f3fe\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🧎🏾‍♂️‍➡️ "\U0001f9ce\U0001f3fe\U0000200d\U00002642\U0000200d\U000027a1\U0000fe0f", # 🧎🏾‍♂‍➡️ "\U0001f9ce\U0001f3fe\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1", # 🧎🏾‍♂️‍➡ "\U0001f9ce\U0001f3fe\U0000200d\U00002642\U0000200d\U000027a1", # 🧎🏾‍♂‍➡ "\U0001f9ce\U0001f3fc\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🧎🏼‍♂️‍➡️ "\U0001f9ce\U0001f3fc\U0000200d\U00002642\U0000200d\U000027a1\U0000fe0f", # 🧎🏼‍♂‍➡️ "\U0001f9ce\U0001f3fc\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1", # 🧎🏼‍♂️‍➡ "\U0001f9ce\U0001f3fc\U0000200d\U00002642\U0000200d\U000027a1", # 🧎🏼‍♂‍➡ "\U0001f9ce\U0001f3fd\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🧎🏽‍♂️‍➡️ "\U0001f9ce\U0001f3fd\U0000200d\U00002642\U0000200d\U000027a1\U0000fe0f", # 🧎🏽‍♂‍➡️ "\U0001f9ce\U0001f3fd\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1", # 🧎🏽‍♂️‍➡ "\U0001f9ce\U0001f3fd\U0000200d\U00002642\U0000200d\U000027a1", # 🧎🏽‍♂‍➡ "\U0001f9ce\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🧎🏻‍♂️ "\U0001f9ce\U0001f3fb\U0000200d\U00002642", # 🧎🏻‍♂ "\U0001f9ce\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🧎🏾‍♂️ "\U0001f9ce\U0001f3fe\U0000200d\U00002642", # 🧎🏾‍♂ "\U0001f9ce\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🧎🏼‍♂️ "\U0001f9ce\U0001f3fc\U0000200d\U00002642", # 🧎🏼‍♂ "\U0001f9ce\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🧎🏽‍♂️ "\U0001f9ce\U0001f3fd\U0000200d\U00002642", # 🧎🏽‍♂ "\U0001f3cb\U0000fe0f\U0000200d\U00002642\U0000fe0f", # 🏋️‍♂️ "\U0001f3cb\U0000200d\U00002642\U0000fe0f", # 🏋‍♂️ "\U0001f3cb\U0000fe0f\U0000200d\U00002642", # 🏋️‍♂ "\U0001f3cb\U0000200d\U00002642", # 🏋‍♂ "\U0001f3cb\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🏋🏿‍♂️ "\U0001f3cb\U0001f3ff\U0000200d\U00002642", # 🏋🏿‍♂ "\U0001f3cb\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🏋🏻‍♂️ "\U0001f3cb\U0001f3fb\U0000200d\U00002642", # 🏋🏻‍♂ "\U0001f3cb\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🏋🏾‍♂️ "\U0001f3cb\U0001f3fe\U0000200d\U00002642", # 🏋🏾‍♂ "\U0001f3cb\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🏋🏼‍♂️ "\U0001f3cb\U0001f3fc\U0000200d\U00002642", # 🏋🏼‍♂ "\U0001f3cb\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🏋🏽‍♂️ "\U0001f3cb\U0001f3fd\U0000200d\U00002642", # 🏋🏽‍♂ "\U0001f468\U0001f3fb", # 👨🏻 "\U0001f468\U0001f3fb\U0000200d\U0001f9b2", # 👨🏻‍🦲 "\U0001f9d4\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🧔🏻‍♂️ "\U0001f9d4\U0001f3fb\U0000200d\U00002642", # 🧔🏻‍♂ "\U0001f471\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 👱🏻‍♂️ "\U0001f471\U0001f3fb\U0000200d\U00002642", # 👱🏻‍♂ "\U0001f468\U0001f3fb\U0000200d\U0001f9b1", # 👨🏻‍🦱 "\U0001f468\U0001f3fb\U0000200d\U0001f9b0", # 👨🏻‍🦰 "\U0001f468\U0001f3fb\U0000200d\U0001f9b3", # 👨🏻‍🦳 "\U0001f9d9\U0000200d\U00002642\U0000fe0f", # 🧙‍♂️ "\U0001f9d9\U0000200d\U00002642", # 🧙‍♂ "\U0001f9d9\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🧙🏿‍♂️ "\U0001f9d9\U0001f3ff\U0000200d\U00002642", # 🧙🏿‍♂ "\U0001f9d9\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🧙🏻‍♂️ "\U0001f9d9\U0001f3fb\U0000200d\U00002642", # 🧙🏻‍♂ "\U0001f9d9\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🧙🏾‍♂️ "\U0001f9d9\U0001f3fe\U0000200d\U00002642", # 🧙🏾‍♂ "\U0001f9d9\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🧙🏼‍♂️ "\U0001f9d9\U0001f3fc\U0000200d\U00002642", # 🧙🏼‍♂ "\U0001f9d9\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🧙🏽‍♂️ "\U0001f9d9\U0001f3fd\U0000200d\U00002642", # 🧙🏽‍♂ "\U0001f468\U0000200d\U0001f527", # 👨‍🔧 "\U0001f468\U0001f3ff\U0000200d\U0001f527", # 👨🏿‍🔧 "\U0001f468\U0001f3fb\U0000200d\U0001f527", # 👨🏻‍🔧 "\U0001f468\U0001f3fe\U0000200d\U0001f527", # 👨🏾‍🔧 "\U0001f468\U0001f3fc\U0000200d\U0001f527", # 👨🏼‍🔧 "\U0001f468\U0001f3fd\U0000200d\U0001f527", # 👨🏽‍🔧 "\U0001f468\U0001f3fe", # 👨🏾 "\U0001f468\U0001f3fe\U0000200d\U0001f9b2", # 👨🏾‍🦲 "\U0001f9d4\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🧔🏾‍♂️ "\U0001f9d4\U0001f3fe\U0000200d\U00002642", # 🧔🏾‍♂ "\U0001f471\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 👱🏾‍♂️ "\U0001f471\U0001f3fe\U0000200d\U00002642", # 👱🏾‍♂ "\U0001f468\U0001f3fe\U0000200d\U0001f9b1", # 👨🏾‍🦱 "\U0001f468\U0001f3fe\U0000200d\U0001f9b0", # 👨🏾‍🦰 "\U0001f468\U0001f3fe\U0000200d\U0001f9b3", # 👨🏾‍🦳 "\U0001f468\U0001f3fc", # 👨🏼 "\U0001f468\U0001f3fc\U0000200d\U0001f9b2", # 👨🏼‍🦲 "\U0001f9d4\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🧔🏼‍♂️ "\U0001f9d4\U0001f3fc\U0000200d\U00002642", # 🧔🏼‍♂ "\U0001f471\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 👱🏼‍♂️ "\U0001f471\U0001f3fc\U0000200d\U00002642", # 👱🏼‍♂ "\U0001f468\U0001f3fc\U0000200d\U0001f9b1", # 👨🏼‍🦱 "\U0001f468\U0001f3fc\U0000200d\U0001f9b0", # 👨🏼‍🦰 "\U0001f468\U0001f3fc\U0000200d\U0001f9b3", # 👨🏼‍🦳 "\U0001f468\U0001f3fd", # 👨🏽 "\U0001f468\U0001f3fd\U0000200d\U0001f9b2", # 👨🏽‍🦲 "\U0001f9d4\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🧔🏽‍♂️ "\U0001f9d4\U0001f3fd\U0000200d\U00002642", # 🧔🏽‍♂ "\U0001f471\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 👱🏽‍♂️ "\U0001f471\U0001f3fd\U0000200d\U00002642", # 👱🏽‍♂ "\U0001f468\U0001f3fd\U0000200d\U0001f9b1", # 👨🏽‍🦱 "\U0001f468\U0001f3fd\U0000200d\U0001f9b0", # 👨🏽‍🦰 "\U0001f468\U0001f3fd\U0000200d\U0001f9b3", # 👨🏽‍🦳 "\U0001f6b5\U0000200d\U00002642\U0000fe0f", # 🚵‍♂️ "\U0001f6b5\U0000200d\U00002642", # 🚵‍♂ "\U0001f6b5\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🚵🏿‍♂️ "\U0001f6b5\U0001f3ff\U0000200d\U00002642", # 🚵🏿‍♂ "\U0001f6b5\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🚵🏻‍♂️ "\U0001f6b5\U0001f3fb\U0000200d\U00002642", # 🚵🏻‍♂ "\U0001f6b5\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🚵🏾‍♂️ "\U0001f6b5\U0001f3fe\U0000200d\U00002642", # 🚵🏾‍♂ "\U0001f6b5\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🚵🏼‍♂️ "\U0001f6b5\U0001f3fc\U0000200d\U00002642", # 🚵🏼‍♂ "\U0001f6b5\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🚵🏽‍♂️ "\U0001f6b5\U0001f3fd\U0000200d\U00002642", # 🚵🏽‍♂ "\U0001f468\U0000200d\U0001f4bc", # 👨‍💼 "\U0001f468\U0001f3ff\U0000200d\U0001f4bc", # 👨🏿‍💼 "\U0001f468\U0001f3fb\U0000200d\U0001f4bc", # 👨🏻‍💼 "\U0001f468\U0001f3fe\U0000200d\U0001f4bc", # 👨🏾‍💼 "\U0001f468\U0001f3fc\U0000200d\U0001f4bc", # 👨🏼‍💼 "\U0001f468\U0001f3fd\U0000200d\U0001f4bc", # 👨🏽‍💼 "\U0001f468\U0000200d\U00002708\U0000fe0f", # 👨‍✈️ "\U0001f468\U0000200d\U00002708", # 👨‍✈ "\U0001f468\U0001f3ff\U0000200d\U00002708\U0000fe0f", # 👨🏿‍✈️ "\U0001f468\U0001f3ff\U0000200d\U00002708", # 👨🏿‍✈ "\U0001f468\U0001f3fb\U0000200d\U00002708\U0000fe0f", # 👨🏻‍✈️ "\U0001f468\U0001f3fb\U0000200d\U00002708", # 👨🏻‍✈ "\U0001f468\U0001f3fe\U0000200d\U00002708\U0000fe0f", # 👨🏾‍✈️ "\U0001f468\U0001f3fe\U0000200d\U00002708", # 👨🏾‍✈ "\U0001f468\U0001f3fc\U0000200d\U00002708\U0000fe0f", # 👨🏼‍✈️ "\U0001f468\U0001f3fc\U0000200d\U00002708", # 👨🏼‍✈ "\U0001f468\U0001f3fd\U0000200d\U00002708\U0000fe0f", # 👨🏽‍✈️ "\U0001f468\U0001f3fd\U0000200d\U00002708", # 👨🏽‍✈ "\U0001f93e\U0000200d\U00002642\U0000fe0f", # 🤾‍♂️ "\U0001f93e\U0000200d\U00002642", # 🤾‍♂ "\U0001f93e\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🤾🏿‍♂️ "\U0001f93e\U0001f3ff\U0000200d\U00002642", # 🤾🏿‍♂ "\U0001f93e\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🤾🏻‍♂️ "\U0001f93e\U0001f3fb\U0000200d\U00002642", # 🤾🏻‍♂ "\U0001f93e\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🤾🏾‍♂️ "\U0001f93e\U0001f3fe\U0000200d\U00002642", # 🤾🏾‍♂ "\U0001f93e\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🤾🏼‍♂️ "\U0001f93e\U0001f3fc\U0000200d\U00002642", # 🤾🏼‍♂ "\U0001f93e\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🤾🏽‍♂️ "\U0001f93e\U0001f3fd\U0000200d\U00002642", # 🤾🏽‍♂ "\U0001f93d\U0000200d\U00002642\U0000fe0f", # 🤽‍♂️ "\U0001f93d\U0000200d\U00002642", # 🤽‍♂ "\U0001f93d\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🤽🏿‍♂️ "\U0001f93d\U0001f3ff\U0000200d\U00002642", # 🤽🏿‍♂ "\U0001f93d\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🤽🏻‍♂️ "\U0001f93d\U0001f3fb\U0000200d\U00002642", # 🤽🏻‍♂ "\U0001f93d\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🤽🏾‍♂️ "\U0001f93d\U0001f3fe\U0000200d\U00002642", # 🤽🏾‍♂ "\U0001f93d\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🤽🏼‍♂️ "\U0001f93d\U0001f3fc\U0000200d\U00002642", # 🤽🏼‍♂ "\U0001f93d\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🤽🏽‍♂️ "\U0001f93d\U0001f3fd\U0000200d\U00002642", # 🤽🏽‍♂ "\U0001f46e\U0000200d\U00002642\U0000fe0f", # 👮‍♂️ "\U0001f46e\U0000200d\U00002642", # 👮‍♂ "\U0001f46e\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 👮🏿‍♂️ "\U0001f46e\U0001f3ff\U0000200d\U00002642", # 👮🏿‍♂ "\U0001f46e\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 👮🏻‍♂️ "\U0001f46e\U0001f3fb\U0000200d\U00002642", # 👮🏻‍♂ "\U0001f46e\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 👮🏾‍♂️ "\U0001f46e\U0001f3fe\U0000200d\U00002642", # 👮🏾‍♂ "\U0001f46e\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 👮🏼‍♂️ "\U0001f46e\U0001f3fc\U0000200d\U00002642", # 👮🏼‍♂ "\U0001f46e\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 👮🏽‍♂️ "\U0001f46e\U0001f3fd\U0000200d\U00002642", # 👮🏽‍♂ "\U0001f64e\U0000200d\U00002642\U0000fe0f", # 🙎‍♂️ "\U0001f64e\U0000200d\U00002642", # 🙎‍♂ "\U0001f64e\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🙎🏿‍♂️ "\U0001f64e\U0001f3ff\U0000200d\U00002642", # 🙎🏿‍♂ "\U0001f64e\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🙎🏻‍♂️ "\U0001f64e\U0001f3fb\U0000200d\U00002642", # 🙎🏻‍♂ "\U0001f64e\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🙎🏾‍♂️ "\U0001f64e\U0001f3fe\U0000200d\U00002642", # 🙎🏾‍♂ "\U0001f64e\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🙎🏼‍♂️ "\U0001f64e\U0001f3fc\U0000200d\U00002642", # 🙎🏼‍♂ "\U0001f64e\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🙎🏽‍♂️ "\U0001f64e\U0001f3fd\U0000200d\U00002642", # 🙎🏽‍♂ "\U0001f64b\U0000200d\U00002642\U0000fe0f", # 🙋‍♂️ "\U0001f64b\U0000200d\U00002642", # 🙋‍♂ "\U0001f64b\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🙋🏿‍♂️ "\U0001f64b\U0001f3ff\U0000200d\U00002642", # 🙋🏿‍♂ "\U0001f64b\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🙋🏻‍♂️ "\U0001f64b\U0001f3fb\U0000200d\U00002642", # 🙋🏻‍♂ "\U0001f64b\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🙋🏾‍♂️ "\U0001f64b\U0001f3fe\U0000200d\U00002642", # 🙋🏾‍♂ "\U0001f64b\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🙋🏼‍♂️ "\U0001f64b\U0001f3fc\U0000200d\U00002642", # 🙋🏼‍♂ "\U0001f64b\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🙋🏽‍♂️ "\U0001f64b\U0001f3fd\U0000200d\U00002642", # 🙋🏽‍♂ "\U0001f468\U0000200d\U0001f9b0", # 👨‍🦰 "\U0001f6a3\U0000200d\U00002642\U0000fe0f", # 🚣‍♂️ "\U0001f6a3\U0000200d\U00002642", # 🚣‍♂ "\U0001f6a3\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🚣🏿‍♂️ "\U0001f6a3\U0001f3ff\U0000200d\U00002642", # 🚣🏿‍♂ "\U0001f6a3\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🚣🏻‍♂️ "\U0001f6a3\U0001f3fb\U0000200d\U00002642", # 🚣🏻‍♂ "\U0001f6a3\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🚣🏾‍♂️ "\U0001f6a3\U0001f3fe\U0000200d\U00002642", # 🚣🏾‍♂ "\U0001f6a3\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🚣🏼‍♂️ "\U0001f6a3\U0001f3fc\U0000200d\U00002642", # 🚣🏼‍♂ "\U0001f6a3\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🚣🏽‍♂️ "\U0001f6a3\U0001f3fd\U0000200d\U00002642", # 🚣🏽‍♂ "\U0001f3c3\U0000200d\U00002642\U0000fe0f", # 🏃‍♂️ "\U0001f3c3\U0000200d\U00002642", # 🏃‍♂ "\U0001f3c3\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🏃🏿‍♂️ "\U0001f3c3\U0001f3ff\U0000200d\U00002642", # 🏃🏿‍♂ "\U0001f3c3\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🏃‍♂️‍➡️ "\U0001f3c3\U0000200d\U00002642\U0000200d\U000027a1\U0000fe0f", # 🏃‍♂‍➡️ "\U0001f3c3\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1", # 🏃‍♂️‍➡ "\U0001f3c3\U0000200d\U00002642\U0000200d\U000027a1", # 🏃‍♂‍➡ "\U0001f3c3\U0001f3ff\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🏃🏿‍♂️‍➡️ "\U0001f3c3\U0001f3ff\U0000200d\U00002642\U0000200d\U000027a1\U0000fe0f", # 🏃🏿‍♂‍➡️ "\U0001f3c3\U0001f3ff\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1", # 🏃🏿‍♂️‍➡ "\U0001f3c3\U0001f3ff\U0000200d\U00002642\U0000200d\U000027a1", # 🏃🏿‍♂‍➡ "\U0001f3c3\U0001f3fb\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🏃🏻‍♂️‍➡️ "\U0001f3c3\U0001f3fb\U0000200d\U00002642\U0000200d\U000027a1\U0000fe0f", # 🏃🏻‍♂‍➡️ "\U0001f3c3\U0001f3fb\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1", # 🏃🏻‍♂️‍➡ "\U0001f3c3\U0001f3fb\U0000200d\U00002642\U0000200d\U000027a1", # 🏃🏻‍♂‍➡ "\U0001f3c3\U0001f3fe\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🏃🏾‍♂️‍➡️ "\U0001f3c3\U0001f3fe\U0000200d\U00002642\U0000200d\U000027a1\U0000fe0f", # 🏃🏾‍♂‍➡️ "\U0001f3c3\U0001f3fe\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1", # 🏃🏾‍♂️‍➡ "\U0001f3c3\U0001f3fe\U0000200d\U00002642\U0000200d\U000027a1", # 🏃🏾‍♂‍➡ "\U0001f3c3\U0001f3fc\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🏃🏼‍♂️‍➡️ "\U0001f3c3\U0001f3fc\U0000200d\U00002642\U0000200d\U000027a1\U0000fe0f", # 🏃🏼‍♂‍➡️ "\U0001f3c3\U0001f3fc\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1", # 🏃🏼‍♂️‍➡ "\U0001f3c3\U0001f3fc\U0000200d\U00002642\U0000200d\U000027a1", # 🏃🏼‍♂‍➡ "\U0001f3c3\U0001f3fd\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🏃🏽‍♂️‍➡️ "\U0001f3c3\U0001f3fd\U0000200d\U00002642\U0000200d\U000027a1\U0000fe0f", # 🏃🏽‍♂‍➡️ "\U0001f3c3\U0001f3fd\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1", # 🏃🏽‍♂️‍➡ "\U0001f3c3\U0001f3fd\U0000200d\U00002642\U0000200d\U000027a1", # 🏃🏽‍♂‍➡ "\U0001f3c3\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🏃🏻‍♂️ "\U0001f3c3\U0001f3fb\U0000200d\U00002642", # 🏃🏻‍♂ "\U0001f3c3\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🏃🏾‍♂️ "\U0001f3c3\U0001f3fe\U0000200d\U00002642", # 🏃🏾‍♂ "\U0001f3c3\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🏃🏼‍♂️ "\U0001f3c3\U0001f3fc\U0000200d\U00002642", # 🏃🏼‍♂ "\U0001f3c3\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🏃🏽‍♂️ "\U0001f3c3\U0001f3fd\U0000200d\U00002642", # 🏃🏽‍♂ "\U0001f468\U0000200d\U0001f52c", # 👨‍🔬 "\U0001f468\U0001f3ff\U0000200d\U0001f52c", # 👨🏿‍🔬 "\U0001f468\U0001f3fb\U0000200d\U0001f52c", # 👨🏻‍🔬 "\U0001f468\U0001f3fe\U0000200d\U0001f52c", # 👨🏾‍🔬 "\U0001f468\U0001f3fc\U0000200d\U0001f52c", # 👨🏼‍🔬 "\U0001f468\U0001f3fd\U0000200d\U0001f52c", # 👨🏽‍🔬 "\U0001f937\U0000200d\U00002642\U0000fe0f", # 🤷‍♂️ "\U0001f937\U0000200d\U00002642", # 🤷‍♂ "\U0001f937\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🤷🏿‍♂️ "\U0001f937\U0001f3ff\U0000200d\U00002642", # 🤷🏿‍♂ "\U0001f937\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🤷🏻‍♂️ "\U0001f937\U0001f3fb\U0000200d\U00002642", # 🤷🏻‍♂ "\U0001f937\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🤷🏾‍♂️ "\U0001f937\U0001f3fe\U0000200d\U00002642", # 🤷🏾‍♂ "\U0001f937\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🤷🏼‍♂️ "\U0001f937\U0001f3fc\U0000200d\U00002642", # 🤷🏼‍♂ "\U0001f937\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🤷🏽‍♂️ "\U0001f937\U0001f3fd\U0000200d\U00002642", # 🤷🏽‍♂ "\U0001f468\U0000200d\U0001f3a4", # 👨‍🎤 "\U0001f468\U0001f3ff\U0000200d\U0001f3a4", # 👨🏿‍🎤 "\U0001f468\U0001f3fb\U0000200d\U0001f3a4", # 👨🏻‍🎤 "\U0001f468\U0001f3fe\U0000200d\U0001f3a4", # 👨🏾‍🎤 "\U0001f468\U0001f3fc\U0000200d\U0001f3a4", # 👨🏼‍🎤 "\U0001f468\U0001f3fd\U0000200d\U0001f3a4", # 👨🏽‍🎤 "\U0001f9cd\U0000200d\U00002642\U0000fe0f", # 🧍‍♂️ "\U0001f9cd\U0000200d\U00002642", # 🧍‍♂ "\U0001f9cd\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🧍🏿‍♂️ "\U0001f9cd\U0001f3ff\U0000200d\U00002642", # 🧍🏿‍♂ "\U0001f9cd\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🧍🏻‍♂️ "\U0001f9cd\U0001f3fb\U0000200d\U00002642", # 🧍🏻‍♂ "\U0001f9cd\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🧍🏾‍♂️ "\U0001f9cd\U0001f3fe\U0000200d\U00002642", # 🧍🏾‍♂ "\U0001f9cd\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🧍🏼‍♂️ "\U0001f9cd\U0001f3fc\U0000200d\U00002642", # 🧍🏼‍♂ "\U0001f9cd\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🧍🏽‍♂️ "\U0001f9cd\U0001f3fd\U0000200d\U00002642", # 🧍🏽‍♂ "\U0001f468\U0000200d\U0001f393", # 👨‍🎓 "\U0001f468\U0001f3ff\U0000200d\U0001f393", # 👨🏿‍🎓 "\U0001f468\U0001f3fb\U0000200d\U0001f393", # 👨🏻‍🎓 "\U0001f468\U0001f3fe\U0000200d\U0001f393", # 👨🏾‍🎓 "\U0001f468\U0001f3fc\U0000200d\U0001f393", # 👨🏼‍🎓 "\U0001f468\U0001f3fd\U0000200d\U0001f393", # 👨🏽‍🎓 "\U0001f9b8\U0000200d\U00002642\U0000fe0f", # 🦸‍♂️ "\U0001f9b8\U0000200d\U00002642", # 🦸‍♂ "\U0001f9b8\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🦸🏿‍♂️ "\U0001f9b8\U0001f3ff\U0000200d\U00002642", # 🦸🏿‍♂ "\U0001f9b8\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🦸🏻‍♂️ "\U0001f9b8\U0001f3fb\U0000200d\U00002642", # 🦸🏻‍♂ "\U0001f9b8\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🦸🏾‍♂️ "\U0001f9b8\U0001f3fe\U0000200d\U00002642", # 🦸🏾‍♂ "\U0001f9b8\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🦸🏼‍♂️ "\U0001f9b8\U0001f3fc\U0000200d\U00002642", # 🦸🏼‍♂ "\U0001f9b8\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🦸🏽‍♂️ "\U0001f9b8\U0001f3fd\U0000200d\U00002642", # 🦸🏽‍♂ "\U0001f9b9\U0000200d\U00002642\U0000fe0f", # 🦹‍♂️ "\U0001f9b9\U0000200d\U00002642", # 🦹‍♂ "\U0001f9b9\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🦹🏿‍♂️ "\U0001f9b9\U0001f3ff\U0000200d\U00002642", # 🦹🏿‍♂ "\U0001f9b9\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🦹🏻‍♂️ "\U0001f9b9\U0001f3fb\U0000200d\U00002642", # 🦹🏻‍♂ "\U0001f9b9\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🦹🏾‍♂️ "\U0001f9b9\U0001f3fe\U0000200d\U00002642", # 🦹🏾‍♂ "\U0001f9b9\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🦹🏼‍♂️ "\U0001f9b9\U0001f3fc\U0000200d\U00002642", # 🦹🏼‍♂ "\U0001f9b9\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🦹🏽‍♂️ "\U0001f9b9\U0001f3fd\U0000200d\U00002642", # 🦹🏽‍♂ "\U0001f3c4\U0000200d\U00002642\U0000fe0f", # 🏄‍♂️ "\U0001f3c4\U0000200d\U00002642", # 🏄‍♂ "\U0001f3c4\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🏄🏿‍♂️ "\U0001f3c4\U0001f3ff\U0000200d\U00002642", # 🏄🏿‍♂ "\U0001f3c4\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🏄🏻‍♂️ "\U0001f3c4\U0001f3fb\U0000200d\U00002642", # 🏄🏻‍♂ "\U0001f3c4\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🏄🏾‍♂️ "\U0001f3c4\U0001f3fe\U0000200d\U00002642", # 🏄🏾‍♂ "\U0001f3c4\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🏄🏼‍♂️ "\U0001f3c4\U0001f3fc\U0000200d\U00002642", # 🏄🏼‍♂ "\U0001f3c4\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🏄🏽‍♂️ "\U0001f3c4\U0001f3fd\U0000200d\U00002642", # 🏄🏽‍♂ "\U0001f3ca\U0000200d\U00002642\U0000fe0f", # 🏊‍♂️ "\U0001f3ca\U0000200d\U00002642", # 🏊‍♂ "\U0001f3ca\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🏊🏿‍♂️ "\U0001f3ca\U0001f3ff\U0000200d\U00002642", # 🏊🏿‍♂ "\U0001f3ca\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🏊🏻‍♂️ "\U0001f3ca\U0001f3fb\U0000200d\U00002642", # 🏊🏻‍♂ "\U0001f3ca\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🏊🏾‍♂️ "\U0001f3ca\U0001f3fe\U0000200d\U00002642", # 🏊🏾‍♂ "\U0001f3ca\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🏊🏼‍♂️ "\U0001f3ca\U0001f3fc\U0000200d\U00002642", # 🏊🏼‍♂ "\U0001f3ca\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🏊🏽‍♂️ "\U0001f3ca\U0001f3fd\U0000200d\U00002642", # 🏊🏽‍♂ "\U0001f468\U0000200d\U0001f3eb", # 👨‍🏫 "\U0001f468\U0001f3ff\U0000200d\U0001f3eb", # 👨🏿‍🏫 "\U0001f468\U0001f3fb\U0000200d\U0001f3eb", # 👨🏻‍🏫 "\U0001f468\U0001f3fe\U0000200d\U0001f3eb", # 👨🏾‍🏫 "\U0001f468\U0001f3fc\U0000200d\U0001f3eb", # 👨🏼‍🏫 "\U0001f468\U0001f3fd\U0000200d\U0001f3eb", # 👨🏽‍🏫 "\U0001f468\U0000200d\U0001f4bb", # 👨‍💻 "\U0001f468\U0001f3ff\U0000200d\U0001f4bb", # 👨🏿‍💻 "\U0001f468\U0001f3fb\U0000200d\U0001f4bb", # 👨🏻‍💻 "\U0001f468\U0001f3fe\U0000200d\U0001f4bb", # 👨🏾‍💻 "\U0001f468\U0001f3fc\U0000200d\U0001f4bb", # 👨🏼‍💻 "\U0001f468\U0001f3fd\U0000200d\U0001f4bb", # 👨🏽‍💻 "\U0001f481\U0000200d\U00002642\U0000fe0f", # 💁‍♂️ "\U0001f481\U0000200d\U00002642", # 💁‍♂ "\U0001f481\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 💁🏿‍♂️ "\U0001f481\U0001f3ff\U0000200d\U00002642", # 💁🏿‍♂ "\U0001f481\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 💁🏻‍♂️ "\U0001f481\U0001f3fb\U0000200d\U00002642", # 💁🏻‍♂ "\U0001f481\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 💁🏾‍♂️ "\U0001f481\U0001f3fe\U0000200d\U00002642", # 💁🏾‍♂ "\U0001f481\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 💁🏼‍♂️ "\U0001f481\U0001f3fc\U0000200d\U00002642", # 💁🏼‍♂ "\U0001f481\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 💁🏽‍♂️ "\U0001f481\U0001f3fd\U0000200d\U00002642", # 💁🏽‍♂ "\U0001f9db\U0000200d\U00002642\U0000fe0f", # 🧛‍♂️ "\U0001f9db\U0000200d\U00002642", # 🧛‍♂ "\U0001f9db\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🧛🏿‍♂️ "\U0001f9db\U0001f3ff\U0000200d\U00002642", # 🧛🏿‍♂ "\U0001f9db\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🧛🏻‍♂️ "\U0001f9db\U0001f3fb\U0000200d\U00002642", # 🧛🏻‍♂ "\U0001f9db\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🧛🏾‍♂️ "\U0001f9db\U0001f3fe\U0000200d\U00002642", # 🧛🏾‍♂ "\U0001f9db\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🧛🏼‍♂️ "\U0001f9db\U0001f3fc\U0000200d\U00002642", # 🧛🏼‍♂ "\U0001f9db\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🧛🏽‍♂️ "\U0001f9db\U0001f3fd\U0000200d\U00002642", # 🧛🏽‍♂ "\U0001f6b6\U0000200d\U00002642\U0000fe0f", # 🚶‍♂️ "\U0001f6b6\U0000200d\U00002642", # 🚶‍♂ "\U0001f6b6\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🚶🏿‍♂️ "\U0001f6b6\U0001f3ff\U0000200d\U00002642", # 🚶🏿‍♂ "\U0001f6b6\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🚶‍♂️‍➡️ "\U0001f6b6\U0000200d\U00002642\U0000200d\U000027a1\U0000fe0f", # 🚶‍♂‍➡️ "\U0001f6b6\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1", # 🚶‍♂️‍➡ "\U0001f6b6\U0000200d\U00002642\U0000200d\U000027a1", # 🚶‍♂‍➡ "\U0001f6b6\U0001f3ff\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🚶🏿‍♂️‍➡️ "\U0001f6b6\U0001f3ff\U0000200d\U00002642\U0000200d\U000027a1\U0000fe0f", # 🚶🏿‍♂‍➡️ "\U0001f6b6\U0001f3ff\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1", # 🚶🏿‍♂️‍➡ "\U0001f6b6\U0001f3ff\U0000200d\U00002642\U0000200d\U000027a1", # 🚶🏿‍♂‍➡ "\U0001f6b6\U0001f3fb\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🚶🏻‍♂️‍➡️ "\U0001f6b6\U0001f3fb\U0000200d\U00002642\U0000200d\U000027a1\U0000fe0f", # 🚶🏻‍♂‍➡️ "\U0001f6b6\U0001f3fb\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1", # 🚶🏻‍♂️‍➡ "\U0001f6b6\U0001f3fb\U0000200d\U00002642\U0000200d\U000027a1", # 🚶🏻‍♂‍➡ "\U0001f6b6\U0001f3fe\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🚶🏾‍♂️‍➡️ "\U0001f6b6\U0001f3fe\U0000200d\U00002642\U0000200d\U000027a1\U0000fe0f", # 🚶🏾‍♂‍➡️ "\U0001f6b6\U0001f3fe\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1", # 🚶🏾‍♂️‍➡ "\U0001f6b6\U0001f3fe\U0000200d\U00002642\U0000200d\U000027a1", # 🚶🏾‍♂‍➡ "\U0001f6b6\U0001f3fc\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🚶🏼‍♂️‍➡️ "\U0001f6b6\U0001f3fc\U0000200d\U00002642\U0000200d\U000027a1\U0000fe0f", # 🚶🏼‍♂‍➡️ "\U0001f6b6\U0001f3fc\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1", # 🚶🏼‍♂️‍➡ "\U0001f6b6\U0001f3fc\U0000200d\U00002642\U0000200d\U000027a1", # 🚶🏼‍♂‍➡ "\U0001f6b6\U0001f3fd\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🚶🏽‍♂️‍➡️ "\U0001f6b6\U0001f3fd\U0000200d\U00002642\U0000200d\U000027a1\U0000fe0f", # 🚶🏽‍♂‍➡️ "\U0001f6b6\U0001f3fd\U0000200d\U00002642\U0000fe0f\U0000200d\U000027a1", # 🚶🏽‍♂️‍➡ "\U0001f6b6\U0001f3fd\U0000200d\U00002642\U0000200d\U000027a1", # 🚶🏽‍♂‍➡ "\U0001f6b6\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🚶🏻‍♂️ "\U0001f6b6\U0001f3fb\U0000200d\U00002642", # 🚶🏻‍♂ "\U0001f6b6\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🚶🏾‍♂️ "\U0001f6b6\U0001f3fe\U0000200d\U00002642", # 🚶🏾‍♂ "\U0001f6b6\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🚶🏼‍♂️ "\U0001f6b6\U0001f3fc\U0000200d\U00002642", # 🚶🏼‍♂ "\U0001f6b6\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🚶🏽‍♂️ "\U0001f6b6\U0001f3fd\U0000200d\U00002642", # 🚶🏽‍♂ "\U0001f473\U0000200d\U00002642\U0000fe0f", # 👳‍♂️ "\U0001f473\U0000200d\U00002642", # 👳‍♂ "\U0001f473\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 👳🏿‍♂️ "\U0001f473\U0001f3ff\U0000200d\U00002642", # 👳🏿‍♂ "\U0001f473\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 👳🏻‍♂️ "\U0001f473\U0001f3fb\U0000200d\U00002642", # 👳🏻‍♂ "\U0001f473\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 👳🏾‍♂️ "\U0001f473\U0001f3fe\U0000200d\U00002642", # 👳🏾‍♂ "\U0001f473\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 👳🏼‍♂️ "\U0001f473\U0001f3fc\U0000200d\U00002642", # 👳🏼‍♂ "\U0001f473\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 👳🏽‍♂️ "\U0001f473\U0001f3fd\U0000200d\U00002642", # 👳🏽‍♂ "\U0001f468\U0000200d\U0001f9b3", # 👨‍🦳 "\U0001f470\U0000200d\U00002642\U0000fe0f", # 👰‍♂️ "\U0001f470\U0000200d\U00002642", # 👰‍♂ "\U0001f470\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 👰🏿‍♂️ "\U0001f470\U0001f3ff\U0000200d\U00002642", # 👰🏿‍♂ "\U0001f470\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 👰🏻‍♂️ "\U0001f470\U0001f3fb\U0000200d\U00002642", # 👰🏻‍♂ "\U0001f470\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 👰🏾‍♂️ "\U0001f470\U0001f3fe\U0000200d\U00002642", # 👰🏾‍♂ "\U0001f470\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 👰🏼‍♂️ "\U0001f470\U0001f3fc\U0000200d\U00002642", # 👰🏼‍♂ "\U0001f470\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 👰🏽‍♂️ "\U0001f470\U0001f3fd\U0000200d\U00002642", # 👰🏽‍♂ "\U0001f468\U0000200d\U0001f9af", # 👨‍🦯 "\U0001f468\U0001f3ff\U0000200d\U0001f9af", # 👨🏿‍🦯 "\U0001f468\U0000200d\U0001f9af\U0000200d\U000027a1\U0000fe0f", # 👨‍🦯‍➡️ "\U0001f468\U0000200d\U0001f9af\U0000200d\U000027a1", # 👨‍🦯‍➡ "\U0001f468\U0001f3ff\U0000200d\U0001f9af\U0000200d\U000027a1\U0000fe0f", # 👨🏿‍🦯‍➡️ "\U0001f468\U0001f3ff\U0000200d\U0001f9af\U0000200d\U000027a1", # 👨🏿‍🦯‍➡ "\U0001f468\U0001f3fb\U0000200d\U0001f9af\U0000200d\U000027a1\U0000fe0f", # 👨🏻‍🦯‍➡️ "\U0001f468\U0001f3fb\U0000200d\U0001f9af\U0000200d\U000027a1", # 👨🏻‍🦯‍➡ "\U0001f468\U0001f3fe\U0000200d\U0001f9af\U0000200d\U000027a1\U0000fe0f", # 👨🏾‍🦯‍➡️ "\U0001f468\U0001f3fe\U0000200d\U0001f9af\U0000200d\U000027a1", # 👨🏾‍🦯‍➡ "\U0001f468\U0001f3fc\U0000200d\U0001f9af\U0000200d\U000027a1\U0000fe0f", # 👨🏼‍🦯‍➡️ "\U0001f468\U0001f3fc\U0000200d\U0001f9af\U0000200d\U000027a1", # 👨🏼‍🦯‍➡ "\U0001f468\U0001f3fd\U0000200d\U0001f9af\U0000200d\U000027a1\U0000fe0f", # 👨🏽‍🦯‍➡️ "\U0001f468\U0001f3fd\U0000200d\U0001f9af\U0000200d\U000027a1", # 👨🏽‍🦯‍➡ "\U0001f468\U0001f3fb\U0000200d\U0001f9af", # 👨🏻‍🦯 "\U0001f468\U0001f3fe\U0000200d\U0001f9af", # 👨🏾‍🦯 "\U0001f468\U0001f3fc\U0000200d\U0001f9af", # 👨🏼‍🦯 "\U0001f468\U0001f3fd\U0000200d\U0001f9af", # 👨🏽‍🦯 "\U0001f9df\U0000200d\U00002642\U0000fe0f", # 🧟‍♂️ "\U0001f9df\U0000200d\U00002642", # 🧟‍♂ "\U0001f96d", # 🥭 "\U0001f570\U0000fe0f", # 🕰️ "\U0001f570", # 🕰 "\U0001f9bd", # 🦽 "\U0001f45e", # 👞 "\U0001f5fe", # 🗾 "\U0001f341", # 🍁 "\U0001fa87", # 🪇 "\U0001f94b", # 🥋 "\U0001f9c9", # 🧉 "\U0001f356", # 🍖 "\U0001f9d1\U0000200d\U0001f527", # 🧑‍🔧 "\U0001f9d1\U0001f3ff\U0000200d\U0001f527", # 🧑🏿‍🔧 "\U0001f9d1\U0001f3fb\U0000200d\U0001f527", # 🧑🏻‍🔧 "\U0001f9d1\U0001f3fe\U0000200d\U0001f527", # 🧑🏾‍🔧 "\U0001f9d1\U0001f3fc\U0000200d\U0001f527", # 🧑🏼‍🔧 "\U0001f9d1\U0001f3fd\U0000200d\U0001f527", # 🧑🏽‍🔧 "\U0001f9be", # 🦾 "\U0001f9bf", # 🦿 "\U00002695\U0000fe0f", # ⚕️ "\U00002695", # ⚕ "\U0001f3fe", # 🏾 "\U0001f3fc", # 🏼 "\U0001f3fd", # 🏽 "\U0001f4e3", # 📣 "\U0001f348", # 🍈 "\U0001fae0", # 🫠 "\U0001f4dd", # 📝 "\U0001f46c", # 👬 "\U0001f46c\U0001f3ff", # 👬🏿 "\U0001f468\U0001f3ff\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fb", # 👨🏿‍🤝‍👨🏻 "\U0001f468\U0001f3ff\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fe", # 👨🏿‍🤝‍👨🏾 "\U0001f468\U0001f3ff\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fc", # 👨🏿‍🤝‍👨🏼 "\U0001f468\U0001f3ff\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fd", # 👨🏿‍🤝‍👨🏽 "\U0001f46c\U0001f3fb", # 👬🏻 "\U0001f468\U0001f3fb\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3ff", # 👨🏻‍🤝‍👨🏿 "\U0001f468\U0001f3fb\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fe", # 👨🏻‍🤝‍👨🏾 "\U0001f468\U0001f3fb\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fc", # 👨🏻‍🤝‍👨🏼 "\U0001f468\U0001f3fb\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fd", # 👨🏻‍🤝‍👨🏽 "\U0001f46c\U0001f3fe", # 👬🏾 "\U0001f468\U0001f3fe\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3ff", # 👨🏾‍🤝‍👨🏿 "\U0001f468\U0001f3fe\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fb", # 👨🏾‍🤝‍👨🏻 "\U0001f468\U0001f3fe\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fc", # 👨🏾‍🤝‍👨🏼 "\U0001f468\U0001f3fe\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fd", # 👨🏾‍🤝‍👨🏽 "\U0001f46c\U0001f3fc", # 👬🏼 "\U0001f468\U0001f3fc\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3ff", # 👨🏼‍🤝‍👨🏿 "\U0001f468\U0001f3fc\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fb", # 👨🏼‍🤝‍👨🏻 "\U0001f468\U0001f3fc\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fe", # 👨🏼‍🤝‍👨🏾 "\U0001f468\U0001f3fc\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fd", # 👨🏼‍🤝‍👨🏽 "\U0001f46c\U0001f3fd", # 👬🏽 "\U0001f468\U0001f3fd\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3ff", # 👨🏽‍🤝‍👨🏿 "\U0001f468\U0001f3fd\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fb", # 👨🏽‍🤝‍👨🏻 "\U0001f468\U0001f3fd\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fe", # 👨🏽‍🤝‍👨🏾 "\U0001f468\U0001f3fd\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fc", # 👨🏽‍🤝‍👨🏼 "\U0001f46f\U0000200d\U00002642\U0000fe0f", # 👯‍♂️ "\U0001f46f\U0000200d\U00002642", # 👯‍♂ "\U0001f93c\U0000200d\U00002642\U0000fe0f", # 🤼‍♂️ "\U0001f93c\U0000200d\U00002642", # 🤼‍♂ "\U00002764\U0000fe0f\U0000200d\U0001fa79", # ❤️‍🩹 "\U00002764\U0000200d\U0001fa79", # ❤‍🩹 "\U0001f54e", # 🕎 "\U0001f6b9", # 🚹 "\U0001f9dc\U0000200d\U00002640\U0000fe0f", # 🧜‍♀️ "\U0001f9dc\U0000200d\U00002640", # 🧜‍♀ "\U0001f9dc\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🧜🏿‍♀️ "\U0001f9dc\U0001f3ff\U0000200d\U00002640", # 🧜🏿‍♀ "\U0001f9dc\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🧜🏻‍♀️ "\U0001f9dc\U0001f3fb\U0000200d\U00002640", # 🧜🏻‍♀ "\U0001f9dc\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🧜🏾‍♀️ "\U0001f9dc\U0001f3fe\U0000200d\U00002640", # 🧜🏾‍♀ "\U0001f9dc\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🧜🏼‍♀️ "\U0001f9dc\U0001f3fc\U0000200d\U00002640", # 🧜🏼‍♀ "\U0001f9dc\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🧜🏽‍♀️ "\U0001f9dc\U0001f3fd\U0000200d\U00002640", # 🧜🏽‍♀ "\U0001f9dc\U0000200d\U00002642\U0000fe0f", # 🧜‍♂️ "\U0001f9dc\U0000200d\U00002642", # 🧜‍♂ "\U0001f9dc\U0001f3ff\U0000200d\U00002642\U0000fe0f", # 🧜🏿‍♂️ "\U0001f9dc\U0001f3ff\U0000200d\U00002642", # 🧜🏿‍♂ "\U0001f9dc\U0001f3fb\U0000200d\U00002642\U0000fe0f", # 🧜🏻‍♂️ "\U0001f9dc\U0001f3fb\U0000200d\U00002642", # 🧜🏻‍♂ "\U0001f9dc\U0001f3fe\U0000200d\U00002642\U0000fe0f", # 🧜🏾‍♂️ "\U0001f9dc\U0001f3fe\U0000200d\U00002642", # 🧜🏾‍♂ "\U0001f9dc\U0001f3fc\U0000200d\U00002642\U0000fe0f", # 🧜🏼‍♂️ "\U0001f9dc\U0001f3fc\U0000200d\U00002642", # 🧜🏼‍♂ "\U0001f9dc\U0001f3fd\U0000200d\U00002642\U0000fe0f", # 🧜🏽‍♂️ "\U0001f9dc\U0001f3fd\U0000200d\U00002642", # 🧜🏽‍♂ "\U0001f9dc", # 🧜 "\U0001f9dc\U0001f3ff", # 🧜🏿 "\U0001f9dc\U0001f3fb", # 🧜🏻 "\U0001f9dc\U0001f3fe", # 🧜🏾 "\U0001f9dc\U0001f3fc", # 🧜🏼 "\U0001f9dc\U0001f3fd", # 🧜🏽 "\U0001f687", # 🚇 "\U0001f9a0", # 🦠 "\U0001f3a4", # 🎤 "\U0001f52c", # 🔬 "\U0001f595", # 🖕 "\U0001f595\U0001f3ff", # 🖕🏿 "\U0001f595\U0001f3fb", # 🖕🏻 "\U0001f595\U0001f3fe", # 🖕🏾 "\U0001f595\U0001f3fc", # 🖕🏼 "\U0001f595\U0001f3fd", # 🖕🏽 "\U0001fa96", # 🪖 "\U0001f396\U0000fe0f", # 🎖️ "\U0001f396", # 🎖 "\U0001f30c", # 🌌 "\U0001f690", # 🚐 "\U00002796", # ➖ "\U0001fa9e", # 🪞 "\U0001faa9", # 🪩 "\U0001f5ff", # 🗿 "\U0001f4f1", # 📱 "\U0001f4f4", # 📴 "\U0001f4f2", # 📲 "\U0001f911", # 🤑 "\U0001f4b0", # 💰 "\U0001f4b8", # 💸 "\U0001f412", # 🐒 "\U0001f435", # 🐵 "\U0001f69d", # 🚝 "\U0001f96e", # 🥮 "\U0001f391", # 🎑 "\U0001face", # 🫎 "\U0001f54c", # 🕌 "\U0001f99f", # 🦟 "\U0001f6e5\U0000fe0f", # 🛥️ "\U0001f6e5", # 🛥 "\U0001f6f5", # 🛵 "\U0001f3cd\U0000fe0f", # 🏍️ "\U0001f3cd", # 🏍 "\U0001f9bc", # 🦼 "\U0001f6e3\U0000fe0f", # 🛣️ "\U0001f6e3", # 🛣 "\U0001f5fb", # 🗻 "\U000026f0\U0000fe0f", # ⛰️ "\U000026f0", # ⛰ "\U0001f6a0", # 🚠 "\U0001f69e", # 🚞 "\U0001f401", # 🐁 "\U0001f42d", # 🐭 "\U0001faa4", # 🪤 "\U0001f444", # 👄 "\U0001f3a5", # 🎥 "\U00002716\U0000fe0f", # ✖️ "\U00002716", # ✖ "\U0001f344", # 🍄 "\U0001f3b9", # 🎹 "\U0001f3b5", # 🎵 "\U0001f3b6", # 🎶 "\U0001f3bc", # 🎼 "\U0001f507", # 🔇 "\U0001f485", # 💅 "\U0001f485\U0001f3ff", # 💅🏿 "\U0001f485\U0001f3fb", # 💅🏻 "\U0001f485\U0001f3fe", # 💅🏾 "\U0001f485\U0001f3fc", # 💅🏼 "\U0001f485\U0001f3fd", # 💅🏽 "\U0001f4db", # 📛 "\U0001f3de\U0000fe0f", # 🏞️ "\U0001f3de", # 🏞 "\U0001f922", # 🤢 "\U0001f9ff", # 🧿 "\U0001f454", # 👔 "\U0001f913", # 🤓 "\U0001faba", # 🪺 "\U0001fa86", # 🪆 "\U0001f610", # 😐 "\U0001f311", # 🌑 "\U0001f31a", # 🌚 "\U0001f4f0", # 📰 "\U000023ed\U0000fe0f", # ⏭️ "\U000023ed", # ⏭ "\U0001f303", # 🌃 "\U0001f564", # 🕤 "\U0001f558", # 🕘 "\U0001f977", # 🥷 "\U0001f977\U0001f3ff", # 🥷🏿 "\U0001f977\U0001f3fb", # 🥷🏻 "\U0001f977\U0001f3fe", # 🥷🏾 "\U0001f977\U0001f3fc", # 🥷🏼 "\U0001f977\U0001f3fd", # 🥷🏽 "\U0001f6b3", # 🚳 "\U000026d4", # ⛔ "\U0001f6af", # 🚯 "\U0001f4f5", # 📵 "\U0001f51e", # 🔞 "\U0001f6b7", # 🚷 "\U0001f6ad", # 🚭 "\U0001f6b1", # 🚱 "\U0001f443", # 👃 "\U0001f443\U0001f3ff", # 👃🏿 "\U0001f443\U0001f3fb", # 👃🏻 "\U0001f443\U0001f3fe", # 👃🏾 "\U0001f443\U0001f3fc", # 👃🏼 "\U0001f443\U0001f3fd", # 👃🏽 "\U0001f4d3", # 📓 "\U0001f4d4", # 📔 "\U0001f529", # 🔩 "\U0001f419", # 🐙 "\U0001f362", # 🍢 "\U0001f3e2", # 🏢 "\U0001f9d1\U0000200d\U0001f4bc", # 🧑‍💼 "\U0001f9d1\U0001f3ff\U0000200d\U0001f4bc", # 🧑🏿‍💼 "\U0001f9d1\U0001f3fb\U0000200d\U0001f4bc", # 🧑🏻‍💼 "\U0001f9d1\U0001f3fe\U0000200d\U0001f4bc", # 🧑🏾‍💼 "\U0001f9d1\U0001f3fc\U0000200d\U0001f4bc", # 🧑🏼‍💼 "\U0001f9d1\U0001f3fd\U0000200d\U0001f4bc", # 🧑🏽‍💼 "\U0001f479", # 👹 "\U0001f6e2\U0000fe0f", # 🛢️ "\U0001f6e2", # 🛢 "\U0001f5dd\U0000fe0f", # 🗝️ "\U0001f5dd", # 🗝 "\U0001f474", # 👴 "\U0001f474\U0001f3ff", # 👴🏿 "\U0001f474\U0001f3fb", # 👴🏻 "\U0001f474\U0001f3fe", # 👴🏾 "\U0001f474\U0001f3fc", # 👴🏼 "\U0001f474\U0001f3fd", # 👴🏽 "\U0001f475", # 👵 "\U0001f475\U0001f3ff", # 👵🏿 "\U0001f475\U0001f3fb", # 👵🏻 "\U0001f475\U0001f3fe", # 👵🏾 "\U0001f475\U0001f3fc", # 👵🏼 "\U0001f475\U0001f3fd", # 👵🏽 "\U0001f9d3", # 🧓 "\U0001f9d3\U0001f3ff", # 🧓🏿 "\U0001f9d3\U0001f3fb", # 🧓🏻 "\U0001f9d3\U0001f3fe", # 🧓🏾 "\U0001f9d3\U0001f3fc", # 🧓🏼 "\U0001f9d3\U0001f3fd", # 🧓🏽 "\U0001fad2", # 🫒 "\U0001f549\U0000fe0f", # 🕉️ "\U0001f549", # 🕉 "\U0001f698", # 🚘 "\U0001f68d", # 🚍 "\U0001f44a", # 👊 "\U0001f44a\U0001f3ff", # 👊🏿 "\U0001f44a\U0001f3fb", # 👊🏻 "\U0001f44a\U0001f3fe", # 👊🏾 "\U0001f44a\U0001f3fc", # 👊🏼 "\U0001f44a\U0001f3fd", # 👊🏽 "\U0001f694", # 🚔 "\U0001f696", # 🚖 "\U0001fa71", # 🩱 "\U0001f55c", # 🕜 "\U0001f550", # 🕐 "\U0001f9c5", # 🧅 "\U0001f4d6", # 📖 "\U0001f4c2", # 📂 "\U0001f450", # 👐 "\U0001f450\U0001f3ff", # 👐🏿 "\U0001f450\U0001f3fb", # 👐🏻 "\U0001f450\U0001f3fe", # 👐🏾 "\U0001f450\U0001f3fc", # 👐🏼 "\U0001f450\U0001f3fd", # 👐🏽 "\U0001f4ed", # 📭 "\U0001f4ec", # 📬 "\U0001f4bf", # 💿 "\U0001f4d9", # 📙 "\U0001f7e0", # 🟠 "\U0001f9e1", # 🧡 "\U0001f7e7", # 🟧 "\U0001f9a7", # 🦧 "\U00002626\U0000fe0f", # ☦️ "\U00002626", # ☦ "\U0001f9a6", # 🦦 "\U0001f4e4", # 📤 "\U0001f989", # 🦉 "\U0001f402", # 🐂 "\U0001f9aa", # 🦪 "\U0001f4e6", # 📦 "\U0001f4c4", # 📄 "\U0001f4c3", # 📃 "\U0001f4df", # 📟 "\U0001f58c\U0000fe0f", # 🖌️ "\U0001f58c", # 🖌 "\U0001faf3", # 🫳 "\U0001faf3\U0001f3ff", # 🫳🏿 "\U0001faf3\U0001f3fb", # 🫳🏻 "\U0001faf3\U0001f3fe", # 🫳🏾 "\U0001faf3\U0001f3fc", # 🫳🏼 "\U0001faf3\U0001f3fd", # 🫳🏽 "\U0001f334", # 🌴 "\U0001faf4", # 🫴 "\U0001faf4\U0001f3ff", # 🫴🏿 "\U0001faf4\U0001f3fb", # 🫴🏻 "\U0001faf4\U0001f3fe", # 🫴🏾 "\U0001faf4\U0001f3fc", # 🫴🏼 "\U0001faf4\U0001f3fd", # 🫴🏽 "\U0001f932", # 🤲 "\U0001f932\U0001f3ff", # 🤲🏿 "\U0001f932\U0001f3fb", # 🤲🏻 "\U0001f932\U0001f3fe", # 🤲🏾 "\U0001f932\U0001f3fc", # 🤲🏼 "\U0001f932\U0001f3fd", # 🤲🏽 "\U0001f95e", # 🥞 "\U0001f43c", # 🐼 "\U0001f4ce", # 📎 "\U0001fa82", # 🪂 "\U0001f99c", # 🦜 "\U0000303d\U0000fe0f", # 〽️ "\U0000303d", # 〽 "\U0001f389", # 🎉 "\U0001f973", # 🥳 "\U0001f6f3\U0000fe0f", # 🛳️ "\U0001f6f3", # 🛳 "\U0001f6c2", # 🛂 "\U000023f8\U0000fe0f", # ⏸️ "\U000023f8", # ⏸ "\U0001f43e", # 🐾 "\U0001fadb", # 🫛 "\U0000262e\U0000fe0f", # ☮️ "\U0000262e", # ☮ "\U0001f351", # 🍑 "\U0001f99a", # 🦚 "\U0001f95c", # 🥜 "\U0001f350", # 🍐 "\U0001f58a\U0000fe0f", # 🖊️ "\U0001f58a", # 🖊 "\U0000270f\U0000fe0f", # ✏️ "\U0000270f", # ✏ "\U0001f427", # 🐧 "\U0001f614", # 😔 "\U0001f9d1\U0000200d\U0001f91d\U0000200d\U0001f9d1", # 🧑‍🤝‍🧑 "\U0001f9d1\U0001f3ff\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3ff", # 🧑🏿‍🤝‍🧑🏿 "\U0001f9d1\U0001f3ff\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3fb", # 🧑🏿‍🤝‍🧑🏻 "\U0001f9d1\U0001f3ff\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3fe", # 🧑🏿‍🤝‍🧑🏾 "\U0001f9d1\U0001f3ff\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3fc", # 🧑🏿‍🤝‍🧑🏼 "\U0001f9d1\U0001f3ff\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3fd", # 🧑🏿‍🤝‍🧑🏽 "\U0001f9d1\U0001f3fb\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3fb", # 🧑🏻‍🤝‍🧑🏻 "\U0001f9d1\U0001f3fb\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3ff", # 🧑🏻‍🤝‍🧑🏿 "\U0001f9d1\U0001f3fb\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3fe", # 🧑🏻‍🤝‍🧑🏾 "\U0001f9d1\U0001f3fb\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3fc", # 🧑🏻‍🤝‍🧑🏼 "\U0001f9d1\U0001f3fb\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3fd", # 🧑🏻‍🤝‍🧑🏽 "\U0001f9d1\U0001f3fe\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3fe", # 🧑🏾‍🤝‍🧑🏾 "\U0001f9d1\U0001f3fe\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3ff", # 🧑🏾‍🤝‍🧑🏿 "\U0001f9d1\U0001f3fe\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3fb", # 🧑🏾‍🤝‍🧑🏻 "\U0001f9d1\U0001f3fe\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3fc", # 🧑🏾‍🤝‍🧑🏼 "\U0001f9d1\U0001f3fe\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3fd", # 🧑🏾‍🤝‍🧑🏽 "\U0001f9d1\U0001f3fc\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3fc", # 🧑🏼‍🤝‍🧑🏼 "\U0001f9d1\U0001f3fc\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3ff", # 🧑🏼‍🤝‍🧑🏿 "\U0001f9d1\U0001f3fc\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3fb", # 🧑🏼‍🤝‍🧑🏻 "\U0001f9d1\U0001f3fc\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3fe", # 🧑🏼‍🤝‍🧑🏾 "\U0001f9d1\U0001f3fc\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3fd", # 🧑🏼‍🤝‍🧑🏽 "\U0001f9d1\U0001f3fd\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3fd", # 🧑🏽‍🤝‍🧑🏽 "\U0001f9d1\U0001f3fd\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3ff", # 🧑🏽‍🤝‍🧑🏿 "\U0001f9d1\U0001f3fd\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3fb", # 🧑🏽‍🤝‍🧑🏻 "\U0001f9d1\U0001f3fd\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3fe", # 🧑🏽‍🤝‍🧑🏾 "\U0001f9d1\U0001f3fd\U0000200d\U0001f91d\U0000200d\U0001f9d1\U0001f3fc", # 🧑🏽‍🤝‍🧑🏼 "\U0001fac2", # 🫂 "\U0001f46f", # 👯 "\U0001f93c", # 🤼 "\U0001f3ad", # 🎭 "\U0001f623", # 😣 "\U0001f9d1", # 🧑 "\U0001f9d1\U0000200d\U0001f9b2", # 🧑‍🦲 "\U0001f9d4", # 🧔 "\U0001f6b4", # 🚴 "\U0001f6b4\U0001f3ff", # 🚴🏿 "\U0001f6b4\U0001f3fb", # 🚴🏻 "\U0001f6b4\U0001f3fe", # 🚴🏾 "\U0001f6b4\U0001f3fc", # 🚴🏼 "\U0001f6b4\U0001f3fd", # 🚴🏽 "\U0001f471", # 👱 "\U000026f9\U0000fe0f", # ⛹️ "\U000026f9", # ⛹ "\U000026f9\U0001f3ff", # ⛹🏿 "\U000026f9\U0001f3fb", # ⛹🏻 "\U000026f9\U0001f3fe", # ⛹🏾 "\U000026f9\U0001f3fc", # ⛹🏼 "\U000026f9\U0001f3fd", # ⛹🏽 "\U0001f647", # 🙇 "\U0001f647\U0001f3ff", # 🙇🏿 "\U0001f647\U0001f3fb", # 🙇🏻 "\U0001f647\U0001f3fe", # 🙇🏾 "\U0001f647\U0001f3fc", # 🙇🏼 "\U0001f647\U0001f3fd", # 🙇🏽 "\U0001f938", # 🤸 "\U0001f938\U0001f3ff", # 🤸🏿 "\U0001f938\U0001f3fb", # 🤸🏻 "\U0001f938\U0001f3fe", # 🤸🏾 "\U0001f938\U0001f3fc", # 🤸🏼 "\U0001f938\U0001f3fd", # 🤸🏽 "\U0001f9d7", # 🧗 "\U0001f9d7\U0001f3ff", # 🧗🏿 "\U0001f9d7\U0001f3fb", # 🧗🏻 "\U0001f9d7\U0001f3fe", # 🧗🏾 "\U0001f9d7\U0001f3fc", # 🧗🏼 "\U0001f9d7\U0001f3fd", # 🧗🏽 "\U0001f9d1\U0000200d\U0001f9b1", # 🧑‍🦱 "\U0001f9d1\U0001f3ff", # 🧑🏿 "\U0001f9d1\U0001f3ff\U0000200d\U0001f9b2", # 🧑🏿‍🦲 "\U0001f9d4\U0001f3ff", # 🧔🏿 "\U0001f471\U0001f3ff", # 👱🏿 "\U0001f9d1\U0001f3ff\U0000200d\U0001f9b1", # 🧑🏿‍🦱 "\U0001f9d1\U0001f3ff\U0000200d\U0001f9b0", # 🧑🏿‍🦰 "\U0001f9d1\U0001f3ff\U0000200d\U0001f9b3", # 🧑🏿‍🦳 "\U0001f926", # 🤦 "\U0001f926\U0001f3ff", # 🤦🏿 "\U0001f926\U0001f3fb", # 🤦🏻 "\U0001f926\U0001f3fe", # 🤦🏾 "\U0001f926\U0001f3fc", # 🤦🏼 "\U0001f926\U0001f3fd", # 🤦🏽 "\U0001f9d1\U0000200d\U0001f37c", # 🧑‍🍼 "\U0001f9d1\U0001f3ff\U0000200d\U0001f37c", # 🧑🏿‍🍼 "\U0001f9d1\U0001f3fb\U0000200d\U0001f37c", # 🧑🏻‍🍼 "\U0001f9d1\U0001f3fe\U0000200d\U0001f37c", # 🧑🏾‍🍼 "\U0001f9d1\U0001f3fc\U0000200d\U0001f37c", # 🧑🏼‍🍼 "\U0001f9d1\U0001f3fd\U0000200d\U0001f37c", # 🧑🏽‍🍼 "\U0001f93a", # 🤺 "\U0001f64d", # 🙍 "\U0001f64d\U0001f3ff", # 🙍🏿 "\U0001f64d\U0001f3fb", # 🙍🏻 "\U0001f64d\U0001f3fe", # 🙍🏾 "\U0001f64d\U0001f3fc", # 🙍🏼 "\U0001f64d\U0001f3fd", # 🙍🏽 "\U0001f645", # 🙅 "\U0001f645\U0001f3ff", # 🙅🏿 "\U0001f645\U0001f3fb", # 🙅🏻 "\U0001f645\U0001f3fe", # 🙅🏾 "\U0001f645\U0001f3fc", # 🙅🏼 "\U0001f645\U0001f3fd", # 🙅🏽 "\U0001f646", # 🙆 "\U0001f646\U0001f3ff", # 🙆🏿 "\U0001f646\U0001f3fb", # 🙆🏻 "\U0001f646\U0001f3fe", # 🙆🏾 "\U0001f646\U0001f3fc", # 🙆🏼 "\U0001f646\U0001f3fd", # 🙆🏽 "\U0001f487", # 💇 "\U0001f487\U0001f3ff", # 💇🏿 "\U0001f487\U0001f3fb", # 💇🏻 "\U0001f487\U0001f3fe", # 💇🏾 "\U0001f487\U0001f3fc", # 💇🏼 "\U0001f487\U0001f3fd", # 💇🏽 "\U0001f486", # 💆 "\U0001f486\U0001f3ff", # 💆🏿 "\U0001f486\U0001f3fb", # 💆🏻 "\U0001f486\U0001f3fe", # 💆🏾 "\U0001f486\U0001f3fc", # 💆🏼 "\U0001f486\U0001f3fd", # 💆🏽 "\U0001f3cc\U0000fe0f", # 🏌️ "\U0001f3cc", # 🏌 "\U0001f3cc\U0001f3ff", # 🏌🏿 "\U0001f3cc\U0001f3fb", # 🏌🏻 "\U0001f3cc\U0001f3fe", # 🏌🏾 "\U0001f3cc\U0001f3fc", # 🏌🏼 "\U0001f3cc\U0001f3fd", # 🏌🏽 "\U0001f6cc", # 🛌 "\U0001f6cc\U0001f3ff", # 🛌🏿 "\U0001f6cc\U0001f3fb", # 🛌🏻 "\U0001f6cc\U0001f3fe", # 🛌🏾 "\U0001f6cc\U0001f3fc", # 🛌🏼 "\U0001f6cc\U0001f3fd", # 🛌🏽 "\U0001f9d8", # 🧘 "\U0001f9d8\U0001f3ff", # 🧘🏿 "\U0001f9d8\U0001f3fb", # 🧘🏻 "\U0001f9d8\U0001f3fe", # 🧘🏾 "\U0001f9d8\U0001f3fc", # 🧘🏼 "\U0001f9d8\U0001f3fd", # 🧘🏽 "\U0001f9d1\U0000200d\U0001f9bd", # 🧑‍🦽 "\U0001f9d1\U0001f3ff\U0000200d\U0001f9bd", # 🧑🏿‍🦽 "\U0001f9d1\U0000200d\U0001f9bd\U0000200d\U000027a1\U0000fe0f", # 🧑‍🦽‍➡️ "\U0001f9d1\U0000200d\U0001f9bd\U0000200d\U000027a1", # 🧑‍🦽‍➡ "\U0001f9d1\U0001f3ff\U0000200d\U0001f9bd\U0000200d\U000027a1\U0000fe0f", # 🧑🏿‍🦽‍➡️ "\U0001f9d1\U0001f3ff\U0000200d\U0001f9bd\U0000200d\U000027a1", # 🧑🏿‍🦽‍➡ "\U0001f9d1\U0001f3fb\U0000200d\U0001f9bd\U0000200d\U000027a1\U0000fe0f", # 🧑🏻‍🦽‍➡️ "\U0001f9d1\U0001f3fb\U0000200d\U0001f9bd\U0000200d\U000027a1", # 🧑🏻‍🦽‍➡ "\U0001f9d1\U0001f3fe\U0000200d\U0001f9bd\U0000200d\U000027a1\U0000fe0f", # 🧑🏾‍🦽‍➡️ "\U0001f9d1\U0001f3fe\U0000200d\U0001f9bd\U0000200d\U000027a1", # 🧑🏾‍🦽‍➡ "\U0001f9d1\U0001f3fc\U0000200d\U0001f9bd\U0000200d\U000027a1\U0000fe0f", # 🧑🏼‍🦽‍➡️ "\U0001f9d1\U0001f3fc\U0000200d\U0001f9bd\U0000200d\U000027a1", # 🧑🏼‍🦽‍➡ "\U0001f9d1\U0001f3fd\U0000200d\U0001f9bd\U0000200d\U000027a1\U0000fe0f", # 🧑🏽‍🦽‍➡️ "\U0001f9d1\U0001f3fd\U0000200d\U0001f9bd\U0000200d\U000027a1", # 🧑🏽‍🦽‍➡ "\U0001f9d1\U0001f3fb\U0000200d\U0001f9bd", # 🧑🏻‍🦽 "\U0001f9d1\U0001f3fe\U0000200d\U0001f9bd", # 🧑🏾‍🦽 "\U0001f9d1\U0001f3fc\U0000200d\U0001f9bd", # 🧑🏼‍🦽 "\U0001f9d1\U0001f3fd\U0000200d\U0001f9bd", # 🧑🏽‍🦽 "\U0001f9d1\U0000200d\U0001f9bc", # 🧑‍🦼 "\U0001f9d1\U0001f3ff\U0000200d\U0001f9bc", # 🧑🏿‍🦼 "\U0001f9d1\U0000200d\U0001f9bc\U0000200d\U000027a1\U0000fe0f", # 🧑‍🦼‍➡️ "\U0001f9d1\U0000200d\U0001f9bc\U0000200d\U000027a1", # 🧑‍🦼‍➡ "\U0001f9d1\U0001f3ff\U0000200d\U0001f9bc\U0000200d\U000027a1\U0000fe0f", # 🧑🏿‍🦼‍➡️ "\U0001f9d1\U0001f3ff\U0000200d\U0001f9bc\U0000200d\U000027a1", # 🧑🏿‍🦼‍➡ "\U0001f9d1\U0001f3fb\U0000200d\U0001f9bc\U0000200d\U000027a1\U0000fe0f", # 🧑🏻‍🦼‍➡️ "\U0001f9d1\U0001f3fb\U0000200d\U0001f9bc\U0000200d\U000027a1", # 🧑🏻‍🦼‍➡ "\U0001f9d1\U0001f3fe\U0000200d\U0001f9bc\U0000200d\U000027a1\U0000fe0f", # 🧑🏾‍🦼‍➡️ "\U0001f9d1\U0001f3fe\U0000200d\U0001f9bc\U0000200d\U000027a1", # 🧑🏾‍🦼‍➡ "\U0001f9d1\U0001f3fc\U0000200d\U0001f9bc\U0000200d\U000027a1\U0000fe0f", # 🧑🏼‍🦼‍➡️ "\U0001f9d1\U0001f3fc\U0000200d\U0001f9bc\U0000200d\U000027a1", # 🧑🏼‍🦼‍➡ "\U0001f9d1\U0001f3fd\U0000200d\U0001f9bc\U0000200d\U000027a1\U0000fe0f", # 🧑🏽‍🦼‍➡️ "\U0001f9d1\U0001f3fd\U0000200d\U0001f9bc\U0000200d\U000027a1", # 🧑🏽‍🦼‍➡ "\U0001f9d1\U0001f3fb\U0000200d\U0001f9bc", # 🧑🏻‍🦼 "\U0001f9d1\U0001f3fe\U0000200d\U0001f9bc", # 🧑🏾‍🦼 "\U0001f9d1\U0001f3fc\U0000200d\U0001f9bc", # 🧑🏼‍🦼 "\U0001f9d1\U0001f3fd\U0000200d\U0001f9bc", # 🧑🏽‍🦼 "\U0001f9d6", # 🧖 "\U0001f9d6\U0001f3ff", # 🧖🏿 "\U0001f9d6\U0001f3fb", # 🧖🏻 "\U0001f9d6\U0001f3fe", # 🧖🏾 "\U0001f9d6\U0001f3fc", # 🧖🏼 "\U0001f9d6\U0001f3fd", # 🧖🏽 "\U0001f574\U0000fe0f", # 🕴️ "\U0001f574", # 🕴 "\U0001f574\U0001f3ff", # 🕴🏿 "\U0001f574\U0001f3fb", # 🕴🏻 "\U0001f574\U0001f3fe", # 🕴🏾 "\U0001f574\U0001f3fc", # 🕴🏼 "\U0001f574\U0001f3fd", # 🕴🏽 "\U0001f935", # 🤵 "\U0001f935\U0001f3ff", # 🤵🏿 "\U0001f935\U0001f3fb", # 🤵🏻 "\U0001f935\U0001f3fe", # 🤵🏾 "\U0001f935\U0001f3fc", # 🤵🏼 "\U0001f935\U0001f3fd", # 🤵🏽 "\U0001f939", # 🤹 "\U0001f939\U0001f3ff", # 🤹🏿 "\U0001f939\U0001f3fb", # 🤹🏻 "\U0001f939\U0001f3fe", # 🤹🏾 "\U0001f939\U0001f3fc", # 🤹🏼 "\U0001f939\U0001f3fd", # 🤹🏽 "\U0001f9ce", # 🧎 "\U0001f9ce\U0001f3ff", # 🧎🏿 "\U0001f9ce\U0000200d\U000027a1\U0000fe0f", # 🧎‍➡️ "\U0001f9ce\U0000200d\U000027a1", # 🧎‍➡ "\U0001f9ce\U0001f3ff\U0000200d\U000027a1\U0000fe0f", # 🧎🏿‍➡️ "\U0001f9ce\U0001f3ff\U0000200d\U000027a1", # 🧎🏿‍➡ "\U0001f9ce\U0001f3fb\U0000200d\U000027a1\U0000fe0f", # 🧎🏻‍➡️ "\U0001f9ce\U0001f3fb\U0000200d\U000027a1", # 🧎🏻‍➡ "\U0001f9ce\U0001f3fe\U0000200d\U000027a1\U0000fe0f", # 🧎🏾‍➡️ "\U0001f9ce\U0001f3fe\U0000200d\U000027a1", # 🧎🏾‍➡ "\U0001f9ce\U0001f3fc\U0000200d\U000027a1\U0000fe0f", # 🧎🏼‍➡️ "\U0001f9ce\U0001f3fc\U0000200d\U000027a1", # 🧎🏼‍➡ "\U0001f9ce\U0001f3fd\U0000200d\U000027a1\U0000fe0f", # 🧎🏽‍➡️ "\U0001f9ce\U0001f3fd\U0000200d\U000027a1", # 🧎🏽‍➡ "\U0001f9ce\U0001f3fb", # 🧎🏻 "\U0001f9ce\U0001f3fe", # 🧎🏾 "\U0001f9ce\U0001f3fc", # 🧎🏼 "\U0001f9ce\U0001f3fd", # 🧎🏽 "\U0001f3cb\U0000fe0f", # 🏋️ "\U0001f3cb", # 🏋 "\U0001f3cb\U0001f3ff", # 🏋🏿 "\U0001f3cb\U0001f3fb", # 🏋🏻 "\U0001f3cb\U0001f3fe", # 🏋🏾 "\U0001f3cb\U0001f3fc", # 🏋🏼 "\U0001f3cb\U0001f3fd", # 🏋🏽 "\U0001f9d1\U0001f3fb", # 🧑🏻 "\U0001f9d1\U0001f3fb\U0000200d\U0001f9b2", # 🧑🏻‍🦲 "\U0001f9d4\U0001f3fb", # 🧔🏻 "\U0001f471\U0001f3fb", # 👱🏻 "\U0001f9d1\U0001f3fb\U0000200d\U0001f9b1", # 🧑🏻‍🦱 "\U0001f9d1\U0001f3fb\U0000200d\U0001f9b0", # 🧑🏻‍🦰 "\U0001f9d1\U0001f3fb\U0000200d\U0001f9b3", # 🧑🏻‍🦳 "\U0001f9d1\U0001f3fe", # 🧑🏾 "\U0001f9d1\U0001f3fe\U0000200d\U0001f9b2", # 🧑🏾‍🦲 "\U0001f9d4\U0001f3fe", # 🧔🏾 "\U0001f471\U0001f3fe", # 👱🏾 "\U0001f9d1\U0001f3fe\U0000200d\U0001f9b1", # 🧑🏾‍🦱 "\U0001f9d1\U0001f3fe\U0000200d\U0001f9b0", # 🧑🏾‍🦰 "\U0001f9d1\U0001f3fe\U0000200d\U0001f9b3", # 🧑🏾‍🦳 "\U0001f9d1\U0001f3fc", # 🧑🏼 "\U0001f9d1\U0001f3fc\U0000200d\U0001f9b2", # 🧑🏼‍🦲 "\U0001f9d4\U0001f3fc", # 🧔🏼 "\U0001f471\U0001f3fc", # 👱🏼 "\U0001f9d1\U0001f3fc\U0000200d\U0001f9b1", # 🧑🏼‍🦱 "\U0001f9d1\U0001f3fc\U0000200d\U0001f9b0", # 🧑🏼‍🦰 "\U0001f9d1\U0001f3fc\U0000200d\U0001f9b3", # 🧑🏼‍🦳 "\U0001f9d1\U0001f3fd", # 🧑🏽 "\U0001f9d1\U0001f3fd\U0000200d\U0001f9b2", # 🧑🏽‍🦲 "\U0001f9d4\U0001f3fd", # 🧔🏽 "\U0001f471\U0001f3fd", # 👱🏽 "\U0001f9d1\U0001f3fd\U0000200d\U0001f9b1", # 🧑🏽‍🦱 "\U0001f9d1\U0001f3fd\U0000200d\U0001f9b0", # 🧑🏽‍🦰 "\U0001f9d1\U0001f3fd\U0000200d\U0001f9b3", # 🧑🏽‍🦳 "\U0001f6b5", # 🚵 "\U0001f6b5\U0001f3ff", # 🚵🏿 "\U0001f6b5\U0001f3fb", # 🚵🏻 "\U0001f6b5\U0001f3fe", # 🚵🏾 "\U0001f6b5\U0001f3fc", # 🚵🏼 "\U0001f6b5\U0001f3fd", # 🚵🏽 "\U0001f93e", # 🤾 "\U0001f93e\U0001f3ff", # 🤾🏿 "\U0001f93e\U0001f3fb", # 🤾🏻 "\U0001f93e\U0001f3fe", # 🤾🏾 "\U0001f93e\U0001f3fc", # 🤾🏼 "\U0001f93e\U0001f3fd", # 🤾🏽 "\U0001f93d", # 🤽 "\U0001f93d\U0001f3ff", # 🤽🏿 "\U0001f93d\U0001f3fb", # 🤽🏻 "\U0001f93d\U0001f3fe", # 🤽🏾 "\U0001f93d\U0001f3fc", # 🤽🏼 "\U0001f93d\U0001f3fd", # 🤽🏽 "\U0001f64e", # 🙎 "\U0001f64e\U0001f3ff", # 🙎🏿 "\U0001f64e\U0001f3fb", # 🙎🏻 "\U0001f64e\U0001f3fe", # 🙎🏾 "\U0001f64e\U0001f3fc", # 🙎🏼 "\U0001f64e\U0001f3fd", # 🙎🏽 "\U0001f64b", # 🙋 "\U0001f64b\U0001f3ff", # 🙋🏿 "\U0001f64b\U0001f3fb", # 🙋🏻 "\U0001f64b\U0001f3fe", # 🙋🏾 "\U0001f64b\U0001f3fc", # 🙋🏼 "\U0001f64b\U0001f3fd", # 🙋🏽 "\U0001f9d1\U0000200d\U0001f9b0", # 🧑‍🦰 "\U0001f6a3", # 🚣 "\U0001f6a3\U0001f3ff", # 🚣🏿 "\U0001f6a3\U0001f3fb", # 🚣🏻 "\U0001f6a3\U0001f3fe", # 🚣🏾 "\U0001f6a3\U0001f3fc", # 🚣🏼 "\U0001f6a3\U0001f3fd", # 🚣🏽 "\U0001f3c3", # 🏃 "\U0001f3c3\U0001f3ff", # 🏃🏿 "\U0001f3c3\U0000200d\U000027a1\U0000fe0f", # 🏃‍➡️ "\U0001f3c3\U0000200d\U000027a1", # 🏃‍➡ "\U0001f3c3\U0001f3ff\U0000200d\U000027a1\U0000fe0f", # 🏃🏿‍➡️ "\U0001f3c3\U0001f3ff\U0000200d\U000027a1", # 🏃🏿‍➡ "\U0001f3c3\U0001f3fb\U0000200d\U000027a1\U0000fe0f", # 🏃🏻‍➡️ "\U0001f3c3\U0001f3fb\U0000200d\U000027a1", # 🏃🏻‍➡ "\U0001f3c3\U0001f3fe\U0000200d\U000027a1\U0000fe0f", # 🏃🏾‍➡️ "\U0001f3c3\U0001f3fe\U0000200d\U000027a1", # 🏃🏾‍➡ "\U0001f3c3\U0001f3fc\U0000200d\U000027a1\U0000fe0f", # 🏃🏼‍➡️ "\U0001f3c3\U0001f3fc\U0000200d\U000027a1", # 🏃🏼‍➡ "\U0001f3c3\U0001f3fd\U0000200d\U000027a1\U0000fe0f", # 🏃🏽‍➡️ "\U0001f3c3\U0001f3fd\U0000200d\U000027a1", # 🏃🏽‍➡ "\U0001f3c3\U0001f3fb", # 🏃🏻 "\U0001f3c3\U0001f3fe", # 🏃🏾 "\U0001f3c3\U0001f3fc", # 🏃🏼 "\U0001f3c3\U0001f3fd", # 🏃🏽 "\U0001f937", # 🤷 "\U0001f937\U0001f3ff", # 🤷🏿 "\U0001f937\U0001f3fb", # 🤷🏻 "\U0001f937\U0001f3fe", # 🤷🏾 "\U0001f937\U0001f3fc", # 🤷🏼 "\U0001f937\U0001f3fd", # 🤷🏽 "\U0001f9cd", # 🧍 "\U0001f9cd\U0001f3ff", # 🧍🏿 "\U0001f9cd\U0001f3fb", # 🧍🏻 "\U0001f9cd\U0001f3fe", # 🧍🏾 "\U0001f9cd\U0001f3fc", # 🧍🏼 "\U0001f9cd\U0001f3fd", # 🧍🏽 "\U0001f3c4", # 🏄 "\U0001f3c4\U0001f3ff", # 🏄🏿 "\U0001f3c4\U0001f3fb", # 🏄🏻 "\U0001f3c4\U0001f3fe", # 🏄🏾 "\U0001f3c4\U0001f3fc", # 🏄🏼 "\U0001f3c4\U0001f3fd", # 🏄🏽 "\U0001f3ca", # 🏊 "\U0001f3ca\U0001f3ff", # 🏊🏿 "\U0001f3ca\U0001f3fb", # 🏊🏻 "\U0001f3ca\U0001f3fe", # 🏊🏾 "\U0001f3ca\U0001f3fc", # 🏊🏼 "\U0001f3ca\U0001f3fd", # 🏊🏽 "\U0001f6c0", # 🛀 "\U0001f6c0\U0001f3ff", # 🛀🏿 "\U0001f6c0\U0001f3fb", # 🛀🏻 "\U0001f6c0\U0001f3fe", # 🛀🏾 "\U0001f6c0\U0001f3fc", # 🛀🏼 "\U0001f6c0\U0001f3fd", # 🛀🏽 "\U0001f481", # 💁 "\U0001f481\U0001f3ff", # 💁🏿 "\U0001f481\U0001f3fb", # 💁🏻 "\U0001f481\U0001f3fe", # 💁🏾 "\U0001f481\U0001f3fc", # 💁🏼 "\U0001f481\U0001f3fd", # 💁🏽 "\U0001f6b6", # 🚶 "\U0001f6b6\U0001f3ff", # 🚶🏿 "\U0001f6b6\U0000200d\U000027a1\U0000fe0f", # 🚶‍➡️ "\U0001f6b6\U0000200d\U000027a1", # 🚶‍➡ "\U0001f6b6\U0001f3ff\U0000200d\U000027a1\U0000fe0f", # 🚶🏿‍➡️ "\U0001f6b6\U0001f3ff\U0000200d\U000027a1", # 🚶🏿‍➡ "\U0001f6b6\U0001f3fb\U0000200d\U000027a1\U0000fe0f", # 🚶🏻‍➡️ "\U0001f6b6\U0001f3fb\U0000200d\U000027a1", # 🚶🏻‍➡ "\U0001f6b6\U0001f3fe\U0000200d\U000027a1\U0000fe0f", # 🚶🏾‍➡️ "\U0001f6b6\U0001f3fe\U0000200d\U000027a1", # 🚶🏾‍➡ "\U0001f6b6\U0001f3fc\U0000200d\U000027a1\U0000fe0f", # 🚶🏼‍➡️ "\U0001f6b6\U0001f3fc\U0000200d\U000027a1", # 🚶🏼‍➡ "\U0001f6b6\U0001f3fd\U0000200d\U000027a1\U0000fe0f", # 🚶🏽‍➡️ "\U0001f6b6\U0001f3fd\U0000200d\U000027a1", # 🚶🏽‍➡ "\U0001f6b6\U0001f3fb", # 🚶🏻 "\U0001f6b6\U0001f3fe", # 🚶🏾 "\U0001f6b6\U0001f3fc", # 🚶🏼 "\U0001f6b6\U0001f3fd", # 🚶🏽 "\U0001f473", # 👳 "\U0001f473\U0001f3ff", # 👳🏿 "\U0001f473\U0001f3fb", # 👳🏻 "\U0001f473\U0001f3fe", # 👳🏾 "\U0001f473\U0001f3fc", # 👳🏼 "\U0001f473\U0001f3fd", # 👳🏽 "\U0001f9d1\U0000200d\U0001f9b3", # 🧑‍🦳 "\U0001fac5", # 🫅 "\U0001fac5\U0001f3ff", # 🫅🏿 "\U0001fac5\U0001f3fb", # 🫅🏻 "\U0001fac5\U0001f3fe", # 🫅🏾 "\U0001fac5\U0001f3fc", # 🫅🏼 "\U0001fac5\U0001f3fd", # 🫅🏽 "\U0001f472", # 👲 "\U0001f472\U0001f3ff", # 👲🏿 "\U0001f472\U0001f3fb", # 👲🏻 "\U0001f472\U0001f3fe", # 👲🏾 "\U0001f472\U0001f3fc", # 👲🏼 "\U0001f472\U0001f3fd", # 👲🏽 "\U0001f470", # 👰 "\U0001f470\U0001f3ff", # 👰🏿 "\U0001f470\U0001f3fb", # 👰🏻 "\U0001f470\U0001f3fe", # 👰🏾 "\U0001f470\U0001f3fc", # 👰🏼 "\U0001f470\U0001f3fd", # 👰🏽 "\U0001f9d1\U0000200d\U0001f9af", # 🧑‍🦯 "\U0001f9d1\U0001f3ff\U0000200d\U0001f9af", # 🧑🏿‍🦯 "\U0001f9d1\U0000200d\U0001f9af\U0000200d\U000027a1\U0000fe0f", # 🧑‍🦯‍➡️ "\U0001f9d1\U0000200d\U0001f9af\U0000200d\U000027a1", # 🧑‍🦯‍➡ "\U0001f9d1\U0001f3ff\U0000200d\U0001f9af\U0000200d\U000027a1\U0000fe0f", # 🧑🏿‍🦯‍➡️ "\U0001f9d1\U0001f3ff\U0000200d\U0001f9af\U0000200d\U000027a1", # 🧑🏿‍🦯‍➡ "\U0001f9d1\U0001f3fb\U0000200d\U0001f9af\U0000200d\U000027a1\U0000fe0f", # 🧑🏻‍🦯‍➡️ "\U0001f9d1\U0001f3fb\U0000200d\U0001f9af\U0000200d\U000027a1", # 🧑🏻‍🦯‍➡ "\U0001f9d1\U0001f3fe\U0000200d\U0001f9af\U0000200d\U000027a1\U0000fe0f", # 🧑🏾‍🦯‍➡️ "\U0001f9d1\U0001f3fe\U0000200d\U0001f9af\U0000200d\U000027a1", # 🧑🏾‍🦯‍➡ "\U0001f9d1\U0001f3fc\U0000200d\U0001f9af\U0000200d\U000027a1\U0000fe0f", # 🧑🏼‍🦯‍➡️ "\U0001f9d1\U0001f3fc\U0000200d\U0001f9af\U0000200d\U000027a1", # 🧑🏼‍🦯‍➡ "\U0001f9d1\U0001f3fd\U0000200d\U0001f9af\U0000200d\U000027a1\U0000fe0f", # 🧑🏽‍🦯‍➡️ "\U0001f9d1\U0001f3fd\U0000200d\U0001f9af\U0000200d\U000027a1", # 🧑🏽‍🦯‍➡ "\U0001f9d1\U0001f3fb\U0000200d\U0001f9af", # 🧑🏻‍🦯 "\U0001f9d1\U0001f3fe\U0000200d\U0001f9af", # 🧑🏾‍🦯 "\U0001f9d1\U0001f3fc\U0000200d\U0001f9af", # 🧑🏼‍🦯 "\U0001f9d1\U0001f3fd\U0000200d\U0001f9af", # 🧑🏽‍🦯 "\U0001f9eb", # 🧫 "\U0001f426\U0000200d\U0001f525", # 🐦‍🔥 "\U000026cf\U0000fe0f", # ⛏️ "\U000026cf", # ⛏ "\U0001f6fb", # 🛻 "\U0001f967", # 🥧 "\U0001f416", # 🐖 "\U0001f437", # 🐷 "\U0001f43d", # 🐽 "\U0001f4a9", # 💩 "\U0001f48a", # 💊 "\U0001f9d1\U0000200d\U00002708\U0000fe0f", # 🧑‍✈️ "\U0001f9d1\U0000200d\U00002708", # 🧑‍✈ "\U0001f9d1\U0001f3ff\U0000200d\U00002708\U0000fe0f", # 🧑🏿‍✈️ "\U0001f9d1\U0001f3ff\U0000200d\U00002708", # 🧑🏿‍✈ "\U0001f9d1\U0001f3fb\U0000200d\U00002708\U0000fe0f", # 🧑🏻‍✈️ "\U0001f9d1\U0001f3fb\U0000200d\U00002708", # 🧑🏻‍✈ "\U0001f9d1\U0001f3fe\U0000200d\U00002708\U0000fe0f", # 🧑🏾‍✈️ "\U0001f9d1\U0001f3fe\U0000200d\U00002708", # 🧑🏾‍✈ "\U0001f9d1\U0001f3fc\U0000200d\U00002708\U0000fe0f", # 🧑🏼‍✈️ "\U0001f9d1\U0001f3fc\U0000200d\U00002708", # 🧑🏼‍✈ "\U0001f9d1\U0001f3fd\U0000200d\U00002708\U0000fe0f", # 🧑🏽‍✈️ "\U0001f9d1\U0001f3fd\U0000200d\U00002708", # 🧑🏽‍✈ "\U0001f90c", # 🤌 "\U0001f90c\U0001f3ff", # 🤌🏿 "\U0001f90c\U0001f3fb", # 🤌🏻 "\U0001f90c\U0001f3fe", # 🤌🏾 "\U0001f90c\U0001f3fc", # 🤌🏼 "\U0001f90c\U0001f3fd", # 🤌🏽 "\U0001f90f", # 🤏 "\U0001f90f\U0001f3ff", # 🤏🏿 "\U0001f90f\U0001f3fb", # 🤏🏻 "\U0001f90f\U0001f3fe", # 🤏🏾 "\U0001f90f\U0001f3fc", # 🤏🏼 "\U0001f90f\U0001f3fd", # 🤏🏽 "\U0001f38d", # 🎍 "\U0001f34d", # 🍍 "\U0001f3d3", # 🏓 "\U0001fa77", # 🩷 "\U0001f3f4\U0000200d\U00002620\U0000fe0f", # 🏴‍☠️ "\U0001f3f4\U0000200d\U00002620", # 🏴‍☠ "\U0001f355", # 🍕 "\U0001fa85", # 🪅 "\U0001faa7", # 🪧 "\U0001f6d0", # 🛐 "\U000025b6\U0000fe0f", # ▶️ "\U000025b6", # ▶ "\U000023ef\U0000fe0f", # ⏯️ "\U000023ef", # ⏯ "\U0001f6dd", # 🛝 "\U0001f97a", # 🥺 "\U0001faa0", # 🪠 "\U00002795", # ➕ "\U0001f43b\U0000200d\U00002744\U0000fe0f", # 🐻‍❄️ "\U0001f43b\U0000200d\U00002744", # 🐻‍❄ "\U0001f693", # 🚓 "\U0001f6a8", # 🚨 "\U0001f46e", # 👮 "\U0001f46e\U0001f3ff", # 👮🏿 "\U0001f46e\U0001f3fb", # 👮🏻 "\U0001f46e\U0001f3fe", # 👮🏾 "\U0001f46e\U0001f3fc", # 👮🏼 "\U0001f46e\U0001f3fd", # 👮🏽 "\U0001f429", # 🐩 "\U0001f3b1", # 🎱 "\U0001f37f", # 🍿 "\U0001f3e4", # 🏤 "\U0001f4ef", # 📯 "\U0001f4ee", # 📮 "\U0001f372", # 🍲 "\U0001f6b0", # 🚰 "\U0001f954", # 🥔 "\U0001fab4", # 🪴 "\U0001f357", # 🍗 "\U0001f4b7", # 💷 "\U0001fad7", # 🫗 "\U0001f63e", # 😾 "\U0001f4ff", # 📿 "\U0001fac3", # 🫃 "\U0001fac3\U0001f3ff", # 🫃🏿 "\U0001fac3\U0001f3fb", # 🫃🏻 "\U0001fac3\U0001f3fe", # 🫃🏾 "\U0001fac3\U0001f3fc", # 🫃🏼 "\U0001fac3\U0001f3fd", # 🫃🏽 "\U0001fac4", # 🫄 "\U0001fac4\U0001f3ff", # 🫄🏿 "\U0001fac4\U0001f3fb", # 🫄🏻 "\U0001fac4\U0001f3fe", # 🫄🏾 "\U0001fac4\U0001f3fc", # 🫄🏼 "\U0001fac4\U0001f3fd", # 🫄🏽 "\U0001f930", # 🤰 "\U0001f930\U0001f3ff", # 🤰🏿 "\U0001f930\U0001f3fb", # 🤰🏻 "\U0001f930\U0001f3fe", # 🤰🏾 "\U0001f930\U0001f3fc", # 🤰🏼 "\U0001f930\U0001f3fd", # 🤰🏽 "\U0001f968", # 🥨 "\U0001f934", # 🤴 "\U0001f934\U0001f3ff", # 🤴🏿 "\U0001f934\U0001f3fb", # 🤴🏻 "\U0001f934\U0001f3fe", # 🤴🏾 "\U0001f934\U0001f3fc", # 🤴🏼 "\U0001f934\U0001f3fd", # 🤴🏽 "\U0001f478", # 👸 "\U0001f478\U0001f3ff", # 👸🏿 "\U0001f478\U0001f3fb", # 👸🏻 "\U0001f478\U0001f3fe", # 👸🏾 "\U0001f478\U0001f3fc", # 👸🏼 "\U0001f478\U0001f3fd", # 👸🏽 "\U0001f5a8\U0000fe0f", # 🖨️ "\U0001f5a8", # 🖨 "\U0001f6ab", # 🚫 "\U0001f7e3", # 🟣 "\U0001f49c", # 💜 "\U0001f7ea", # 🟪 "\U0001f45b", # 👛 "\U0001f4cc", # 📌 "\U0001f9e9", # 🧩 "\U0001f407", # 🐇 "\U0001f430", # 🐰 "\U0001f99d", # 🦝 "\U0001f3ce\U0000fe0f", # 🏎️ "\U0001f3ce", # 🏎 "\U0001f4fb", # 📻 "\U0001f518", # 🔘 "\U00002622\U0000fe0f", # ☢️ "\U00002622", # ☢ "\U0001f683", # 🚃 "\U0001f6e4\U0000fe0f", # 🛤️ "\U0001f6e4", # 🛤 "\U0001f308", # 🌈 "\U0001f3f3\U0000fe0f\U0000200d\U0001f308", # 🏳️‍🌈 "\U0001f3f3\U0000200d\U0001f308", # 🏳‍🌈 "\U0001f91a", # 🤚 "\U0001f91a\U0001f3ff", # 🤚🏿 "\U0001f91a\U0001f3fb", # 🤚🏻 "\U0001f91a\U0001f3fe", # 🤚🏾 "\U0001f91a\U0001f3fc", # 🤚🏼 "\U0001f91a\U0001f3fd", # 🤚🏽 "\U0000270a", # ✊ "\U0000270a\U0001f3ff", # ✊🏿 "\U0000270a\U0001f3fb", # ✊🏻 "\U0000270a\U0001f3fe", # ✊🏾 "\U0000270a\U0001f3fc", # ✊🏼 "\U0000270a\U0001f3fd", # ✊🏽 "\U0000270b", # ✋ "\U0000270b\U0001f3ff", # ✋🏿 "\U0000270b\U0001f3fb", # ✋🏻 "\U0000270b\U0001f3fe", # ✋🏾 "\U0000270b\U0001f3fc", # ✋🏼 "\U0000270b\U0001f3fd", # ✋🏽 "\U0001f64c", # 🙌 "\U0001f64c\U0001f3ff", # 🙌🏿 "\U0001f64c\U0001f3fb", # 🙌🏻 "\U0001f64c\U0001f3fe", # 🙌🏾 "\U0001f64c\U0001f3fc", # 🙌🏼 "\U0001f64c\U0001f3fd", # 🙌🏽 "\U0001f40f", # 🐏 "\U0001f400", # 🐀 "\U0001fa92", # 🪒 "\U0001f9fe", # 🧾 "\U000023fa\U0000fe0f", # ⏺️ "\U000023fa", # ⏺ "\U0000267b\U0000fe0f", # ♻️ "\U0000267b", # ♻ "\U0001f34e", # 🍎 "\U0001f534", # 🔴 "\U0001f9e7", # 🧧 "\U00002757", # ❗ "\U0001f9b0", # 🦰 "\U00002764\U0000fe0f", # ❤️ "\U00002764", # ❤ "\U0001f3ee", # 🏮 "\U00002753", # ❓ "\U0001f7e5", # 🟥 "\U0001f53b", # 🔻 "\U0001f53a", # 🔺 "\U000000ae\U0000fe0f", # ®️ "\U000000ae", # ® "\U0001f60c", # 😌 "\U0001f397\U0000fe0f", # 🎗️ "\U0001f397", # 🎗 "\U0001f501", # 🔁 "\U0001f502", # 🔂 "\U000026d1\U0000fe0f", # ⛑️ "\U000026d1", # ⛑ "\U0001f6bb", # 🚻 "\U000025c0\U0000fe0f", # ◀️ "\U000025c0", # ◀ "\U0001f49e", # 💞 "\U0001f98f", # 🦏 "\U0001f380", # 🎀 "\U0001f359", # 🍙 "\U0001f358", # 🍘 "\U0001f91c", # 🤜 "\U0001f91c\U0001f3ff", # 🤜🏿 "\U0001f91c\U0001f3fb", # 🤜🏻 "\U0001f91c\U0001f3fe", # 🤜🏾 "\U0001f91c\U0001f3fc", # 🤜🏼 "\U0001f91c\U0001f3fd", # 🤜🏽 "\U0001f5ef\U0000fe0f", # 🗯️ "\U0001f5ef", # 🗯 "\U000027a1\U0000fe0f", # ➡️ "\U000027a1", # ➡ "\U00002935\U0000fe0f", # ⤵️ "\U00002935", # ⤵ "\U000021a9\U0000fe0f", # ↩️ "\U000021a9", # ↩ "\U00002934\U0000fe0f", # ⤴️ "\U00002934", # ⤴ "\U0001faf1", # 🫱 "\U0001faf1\U0001f3ff", # 🫱🏿 "\U0001faf1\U0001f3fb", # 🫱🏻 "\U0001faf1\U0001f3fe", # 🫱🏾 "\U0001faf1\U0001f3fc", # 🫱🏼 "\U0001faf1\U0001f3fd", # 🫱🏽 "\U0001faf8", # 🫸 "\U0001faf8\U0001f3ff", # 🫸🏿 "\U0001faf8\U0001f3fb", # 🫸🏻 "\U0001faf8\U0001f3fe", # 🫸🏾 "\U0001faf8\U0001f3fc", # 🫸🏼 "\U0001faf8\U0001f3fd", # 🫸🏽 "\U0001f48d", # 💍 "\U0001f6df", # 🛟 "\U0001fa90", # 🪐 "\U0001f360", # 🍠 "\U0001f916", # 🤖 "\U0001faa8", # 🪨 "\U0001f680", # 🚀 "\U0001f9fb", # 🧻 "\U0001f5de\U0000fe0f", # 🗞️ "\U0001f5de", # 🗞 "\U0001f3a2", # 🎢 "\U0001f6fc", # 🛼 "\U0001f923", # 🤣 "\U0001f413", # 🐓 "\U0001fadc", # 🫜 "\U0001f339", # 🌹 "\U0001f3f5\U0000fe0f", # 🏵️ "\U0001f3f5", # 🏵 "\U0001f4cd", # 📍 "\U0001f3c9", # 🏉 "\U0001f3bd", # 🎽 "\U0001f45f", # 👟 "\U0001f625", # 😥 "\U0001f9f7", # 🧷 "\U0001f9ba", # 🦺 "\U000026f5", # ⛵ "\U0001f376", # 🍶 "\U0001f9c2", # 🧂 "\U0001fae1", # 🫡 "\U0001f96a", # 🥪 "\U0001f97b", # 🥻 "\U0001f6f0\U0000fe0f", # 🛰️ "\U0001f6f0", # 🛰 "\U0001f4e1", # 📡 "\U0001f995", # 🦕 "\U0001f3b7", # 🎷 "\U0001f9e3", # 🧣 "\U0001f3eb", # 🏫 "\U0001f9d1\U0000200d\U0001f52c", # 🧑‍🔬 "\U0001f9d1\U0001f3ff\U0000200d\U0001f52c", # 🧑🏿‍🔬 "\U0001f9d1\U0001f3fb\U0000200d\U0001f52c", # 🧑🏻‍🔬 "\U0001f9d1\U0001f3fe\U0000200d\U0001f52c", # 🧑🏾‍🔬 "\U0001f9d1\U0001f3fc\U0000200d\U0001f52c", # 🧑🏼‍🔬 "\U0001f9d1\U0001f3fd\U0000200d\U0001f52c", # 🧑🏽‍🔬 "\U00002702\U0000fe0f", # ✂️ "\U00002702", # ✂ "\U0001f982", # 🦂 "\U0001fa9b", # 🪛 "\U0001f4dc", # 📜 "\U0001f9ad", # 🦭 "\U0001f4ba", # 💺 "\U0001f648", # 🙈 "\U0001f331", # 🌱 "\U0001f933", # 🤳 "\U0001f933\U0001f3ff", # 🤳🏿 "\U0001f933\U0001f3fb", # 🤳🏻 "\U0001f933\U0001f3fe", # 🤳🏾 "\U0001f933\U0001f3fc", # 🤳🏼 "\U0001f933\U0001f3fd", # 🤳🏽 "\U0001f415\U0000200d\U0001f9ba", # 🐕‍🦺 "\U0001f562", # 🕢 "\U0001f556", # 🕖 "\U0001faa1", # 🪡 "\U0001fae8", # 🫨 "\U0001f958", # 🥘 "\U00002618\U0000fe0f", # ☘️ "\U00002618", # ☘ "\U0001f988", # 🦈 "\U0001f367", # 🍧 "\U0001f33e", # 🌾 "\U0001f6e1\U0000fe0f", # 🛡️ "\U0001f6e1", # 🛡 "\U000026e9\U0000fe0f", # ⛩️ "\U000026e9", # ⛩ "\U0001f6a2", # 🚢 "\U0001f320", # 🌠 "\U0001f6cd\U0000fe0f", # 🛍️ "\U0001f6cd", # 🛍 "\U0001f6d2", # 🛒 "\U0001f370", # 🍰 "\U0001fa73", # 🩳 "\U0001fa8f", # 🪏 "\U0001f6bf", # 🚿 "\U0001f990", # 🦐 "\U0001f500", # 🔀 "\U0001f92b", # 🤫 "\U0001f918", # 🤘 "\U0001f918\U0001f3ff", # 🤘🏿 "\U0001f918\U0001f3fb", # 🤘🏻 "\U0001f918\U0001f3fe", # 🤘🏾 "\U0001f918\U0001f3fc", # 🤘🏼 "\U0001f918\U0001f3fd", # 🤘🏽 "\U0001f9d1\U0000200d\U0001f3a4", # 🧑‍🎤 "\U0001f9d1\U0001f3ff\U0000200d\U0001f3a4", # 🧑🏿‍🎤 "\U0001f9d1\U0001f3fb\U0000200d\U0001f3a4", # 🧑🏻‍🎤 "\U0001f9d1\U0001f3fe\U0000200d\U0001f3a4", # 🧑🏾‍🎤 "\U0001f9d1\U0001f3fc\U0000200d\U0001f3a4", # 🧑🏼‍🎤 "\U0001f9d1\U0001f3fd\U0000200d\U0001f3a4", # 🧑🏽‍🎤 "\U0001f561", # 🕡 "\U0001f555", # 🕕 "\U0001f6f9", # 🛹 "\U000026f7\U0000fe0f", # ⛷️ "\U000026f7", # ⛷ "\U0001f3bf", # 🎿 "\U0001f480", # 💀 "\U00002620\U0000fe0f", # ☠️ "\U00002620", # ☠ "\U0001f9a8", # 🦨 "\U0001f6f7", # 🛷 "\U0001f634", # 😴 "\U0001f62a", # 😪 "\U0001f641", # 🙁 "\U0001f642", # 🙂 "\U0001f3b0", # 🎰 "\U0001f9a5", # 🦥 "\U0001f6e9\U0000fe0f", # 🛩️ "\U0001f6e9", # 🛩 "\U0001f539", # 🔹 "\U0001f538", # 🔸 "\U0001f63b", # 😻 "\U0000263a\U0000fe0f", # ☺️ "\U0000263a", # ☺ "\U0001f607", # 😇 "\U0001f60d", # 😍 "\U0001f970", # 🥰 "\U0001f608", # 😈 "\U0001f917", # 🤗 "\U0001f60a", # 😊 "\U0001f60e", # 😎 "\U0001f972", # 🥲 "\U0001f60f", # 😏 "\U0001f40c", # 🐌 "\U0001f40d", # 🐍 "\U0001f927", # 🤧 "\U0001f3d4\U0000fe0f", # 🏔️ "\U0001f3d4", # 🏔 "\U0001f3c2", # 🏂 "\U0001f3c2\U0001f3ff", # 🏂🏿 "\U0001f3c2\U0001f3fb", # 🏂🏻 "\U0001f3c2\U0001f3fe", # 🏂🏾 "\U0001f3c2\U0001f3fc", # 🏂🏼 "\U0001f3c2\U0001f3fd", # 🏂🏽 "\U00002744\U0000fe0f", # ❄️ "\U00002744", # ❄ "\U00002603\U0000fe0f", # ☃️ "\U00002603", # ☃ "\U000026c4", # ⛄ "\U0001f9fc", # 🧼 "\U000026bd", # ⚽ "\U0001f9e6", # 🧦 "\U0001f366", # 🍦 "\U0001f94e", # 🥎 "\U00002660\U0000fe0f", # ♠️ "\U00002660", # ♠ "\U0001f35d", # 🍝 "\U00002747\U0000fe0f", # ❇️ "\U00002747", # ❇ "\U0001f387", # 🎇 "\U00002728", # ✨ "\U0001f496", # 💖 "\U0001f64a", # 🙊 "\U0001f50a", # 🔊 "\U0001f508", # 🔈 "\U0001f509", # 🔉 "\U0001f5e3\U0000fe0f", # 🗣️ "\U0001f5e3", # 🗣 "\U0001f4ac", # 💬 "\U0001f6a4", # 🚤 "\U0001f577\U0000fe0f", # 🕷️ "\U0001f577", # 🕷 "\U0001f578\U0000fe0f", # 🕸️ "\U0001f578", # 🕸 "\U0001f5d3\U0000fe0f", # 🗓️ "\U0001f5d3", # 🗓 "\U0001f5d2\U0000fe0f", # 🗒️ "\U0001f5d2", # 🗒 "\U0001f41a", # 🐚 "\U0001fadf", # 🫟 "\U0001f9fd", # 🧽 "\U0001f944", # 🥄 "\U0001f699", # 🚙 "\U0001f3c5", # 🏅 "\U0001f433", # 🐳 "\U0001f991", # 🦑 "\U0001f61d", # 😝 "\U0001f3df\U0000fe0f", # 🏟️ "\U0001f3df", # 🏟 "\U00002b50", # ⭐ "\U0001f929", # 🤩 "\U0000262a\U0000fe0f", # ☪️ "\U0000262a", # ☪ "\U00002721\U0000fe0f", # ✡️ "\U00002721", # ✡ "\U0001f689", # 🚉 "\U0001f35c", # 🍜 "\U0001fa7a", # 🩺 "\U000023f9\U0000fe0f", # ⏹️ "\U000023f9", # ⏹ "\U0001f6d1", # 🛑 "\U000023f1\U0000fe0f", # ⏱️ "\U000023f1", # ⏱ "\U0001f4cf", # 📏 "\U0001f353", # 🍓 "\U0001f9d1\U0000200d\U0001f393", # 🧑‍🎓 "\U0001f9d1\U0001f3ff\U0000200d\U0001f393", # 🧑🏿‍🎓 "\U0001f9d1\U0001f3fb\U0000200d\U0001f393", # 🧑🏻‍🎓 "\U0001f9d1\U0001f3fe\U0000200d\U0001f393", # 🧑🏾‍🎓 "\U0001f9d1\U0001f3fc\U0000200d\U0001f393", # 🧑🏼‍🎓 "\U0001f9d1\U0001f3fd\U0000200d\U0001f393", # 🧑🏽‍🎓 "\U0001f399\U0000fe0f", # 🎙️ "\U0001f399", # 🎙 "\U0001f959", # 🥙 "\U00002600\U0000fe0f", # ☀️ "\U00002600", # ☀ "\U000026c5", # ⛅ "\U0001f325\U0000fe0f", # 🌥️ "\U0001f325", # 🌥 "\U0001f326\U0000fe0f", # 🌦️ "\U0001f326", # 🌦 "\U0001f324\U0000fe0f", # 🌤️ "\U0001f324", # 🌤 "\U0001f31e", # 🌞 "\U0001f33b", # 🌻 "\U0001f576\U0000fe0f", # 🕶️ "\U0001f576", # 🕶 "\U0001f305", # 🌅 "\U0001f304", # 🌄 "\U0001f307", # 🌇 "\U0001f9b8", # 🦸 "\U0001f9b8\U0001f3ff", # 🦸🏿 "\U0001f9b8\U0001f3fb", # 🦸🏻 "\U0001f9b8\U0001f3fe", # 🦸🏾 "\U0001f9b8\U0001f3fc", # 🦸🏼 "\U0001f9b8\U0001f3fd", # 🦸🏽 "\U0001f9b9", # 🦹 "\U0001f9b9\U0001f3ff", # 🦹🏿 "\U0001f9b9\U0001f3fb", # 🦹🏻 "\U0001f9b9\U0001f3fe", # 🦹🏾 "\U0001f9b9\U0001f3fc", # 🦹🏼 "\U0001f9b9\U0001f3fd", # 🦹🏽 "\U0001f363", # 🍣 "\U0001f69f", # 🚟 "\U0001f9a2", # 🦢 "\U0001f4a6", # 💦 "\U0001f54d", # 🕍 "\U0001f489", # 💉 "\U0001f455", # 👕 "\U0001f32e", # 🌮 "\U0001f961", # 🥡 "\U0001fad4", # 🫔 "\U0001f38b", # 🎋 "\U0001f34a", # 🍊 "\U0001f695", # 🚕 "\U0001f9d1\U0000200d\U0001f3eb", # 🧑‍🏫 "\U0001f9d1\U0001f3ff\U0000200d\U0001f3eb", # 🧑🏿‍🏫 "\U0001f9d1\U0001f3fb\U0000200d\U0001f3eb", # 🧑🏻‍🏫 "\U0001f9d1\U0001f3fe\U0000200d\U0001f3eb", # 🧑🏾‍🏫 "\U0001f9d1\U0001f3fc\U0000200d\U0001f3eb", # 🧑🏼‍🏫 "\U0001f9d1\U0001f3fd\U0000200d\U0001f3eb", # 🧑🏽‍🏫 "\U0001f375", # 🍵 "\U0001fad6", # 🫖 "\U0001f4c6", # 📆 "\U0001f9d1\U0000200d\U0001f4bb", # 🧑‍💻 "\U0001f9d1\U0001f3ff\U0000200d\U0001f4bb", # 🧑🏿‍💻 "\U0001f9d1\U0001f3fb\U0000200d\U0001f4bb", # 🧑🏻‍💻 "\U0001f9d1\U0001f3fe\U0000200d\U0001f4bb", # 🧑🏾‍💻 "\U0001f9d1\U0001f3fc\U0000200d\U0001f4bb", # 🧑🏼‍💻 "\U0001f9d1\U0001f3fd\U0000200d\U0001f4bb", # 🧑🏽‍💻 "\U0001f9f8", # 🧸 "\U0000260e\U0000fe0f", # ☎️ "\U0000260e", # ☎ "\U0001f4de", # 📞 "\U0001f52d", # 🔭 "\U0001f4fa", # 📺 "\U0001f565", # 🕥 "\U0001f559", # 🕙 "\U0001f3be", # 🎾 "\U000026fa", # ⛺ "\U0001f9ea", # 🧪 "\U0001f321\U0000fe0f", # 🌡️ "\U0001f321", # 🌡 "\U0001f914", # 🤔 "\U0001fa74", # 🩴 "\U0001f4ad", # 💭 "\U0001f9f5", # 🧵 "\U0001f55e", # 🕞 "\U0001f552", # 🕒 "\U0001f44e", # 👎 "\U0001f44e\U0001f3ff", # 👎🏿 "\U0001f44e\U0001f3fb", # 👎🏻 "\U0001f44e\U0001f3fe", # 👎🏾 "\U0001f44e\U0001f3fc", # 👎🏼 "\U0001f44e\U0001f3fd", # 👎🏽 "\U0001f44d", # 👍 "\U0001f44d\U0001f3ff", # 👍🏿 "\U0001f44d\U0001f3fb", # 👍🏻 "\U0001f44d\U0001f3fe", # 👍🏾 "\U0001f44d\U0001f3fc", # 👍🏼 "\U0001f44d\U0001f3fd", # 👍🏽 "\U0001f3ab", # 🎫 "\U0001f405", # 🐅 "\U0001f42f", # 🐯 "\U000023f2\U0000fe0f", # ⏲️ "\U000023f2", # ⏲ "\U0001f62b", # 😫 "\U0001f6bd", # 🚽 "\U0001f345", # 🍅 "\U0001f445", # 👅 "\U0001f9f0", # 🧰 "\U0001f9b7", # 🦷 "\U0001faa5", # 🪥 "\U0001f3a9", # 🎩 "\U0001f32a\U0000fe0f", # 🌪️ "\U0001f32a", # 🌪 "\U0001f5b2\U0000fe0f", # 🖲️ "\U0001f5b2", # 🖲 "\U0001f69c", # 🚜 "\U00002122\U0000fe0f", # ™️ "\U00002122", # ™ "\U0001f686", # 🚆 "\U0001f68a", # 🚊 "\U0001f68b", # 🚋 "\U0001f3f3\U0000fe0f\U0000200d\U000026a7\U0000fe0f", # 🏳️‍⚧️ "\U0001f3f3\U0000200d\U000026a7\U0000fe0f", # 🏳‍⚧️ "\U0001f3f3\U0000fe0f\U0000200d\U000026a7", # 🏳️‍⚧ "\U0001f3f3\U0000200d\U000026a7", # 🏳‍⚧ "\U000026a7\U0000fe0f", # ⚧️ "\U000026a7", # ⚧ "\U0001f6a9", # 🚩 "\U0001f4d0", # 📐 "\U0001f531", # 🔱 "\U0001f9cc", # 🧌 "\U0001f68e", # 🚎 "\U0001f3c6", # 🏆 "\U0001f379", # 🍹 "\U0001f420", # 🐠 "\U0001f3ba", # 🎺 "\U0001f337", # 🌷 "\U0001f943", # 🥃 "\U0001f983", # 🦃 "\U0001f422", # 🐢 "\U0001f567", # 🕧 "\U0001f55b", # 🕛 "\U0001f42b", # 🐫 "\U0001f55d", # 🕝 "\U0001f495", # 💕 "\U0001f551", # 🕑 "\U00002602\U0000fe0f", # ☂️ "\U00002602", # ☂ "\U000026f1\U0000fe0f", # ⛱️ "\U000026f1", # ⛱ "\U00002614", # ☔ "\U0001f612", # 😒 "\U0001f984", # 🦄 "\U0001f513", # 🔓 "\U00002195\U0000fe0f", # ↕️ "\U00002195", # ↕ "\U00002196\U0000fe0f", # ↖️ "\U00002196", # ↖ "\U00002197\U0000fe0f", # ↗️ "\U00002197", # ↗ "\U00002b06\U0000fe0f", # ⬆️ "\U00002b06", # ⬆ "\U0001f643", # 🙃 "\U0001f53c", # 🔼 "\U0001f9db", # 🧛 "\U0001f9db\U0001f3ff", # 🧛🏿 "\U0001f9db\U0001f3fb", # 🧛🏻 "\U0001f9db\U0001f3fe", # 🧛🏾 "\U0001f9db\U0001f3fc", # 🧛🏼 "\U0001f9db\U0001f3fd", # 🧛🏽 "\U0001f6a6", # 🚦 "\U0001f4f3", # 📳 "\U0000270c\U0000fe0f", # ✌️ "\U0000270c", # ✌ "\U0000270c\U0001f3ff", # ✌🏿 "\U0000270c\U0001f3fb", # ✌🏻 "\U0000270c\U0001f3fe", # ✌🏾 "\U0000270c\U0001f3fc", # ✌🏼 "\U0000270c\U0001f3fd", # ✌🏽 "\U0001f4f9", # 📹 "\U0001f3ae", # 🎮 "\U0001f4fc", # 📼 "\U0001f3bb", # 🎻 "\U0001f30b", # 🌋 "\U0001f3d0", # 🏐 "\U0001f596", # 🖖 "\U0001f596\U0001f3ff", # 🖖🏿 "\U0001f596\U0001f3fb", # 🖖🏻 "\U0001f596\U0001f3fe", # 🖖🏾 "\U0001f596\U0001f3fc", # 🖖🏼 "\U0001f596\U0001f3fd", # 🖖🏽 "\U0001f9c7", # 🧇 "\U0001f318", # 🌘 "\U0001f316", # 🌖 "\U000026a0\U0000fe0f", # ⚠️ "\U000026a0", # ⚠ "\U0001f5d1\U0000fe0f", # 🗑️ "\U0001f5d1", # 🗑 "\U0000231a", # ⌚ "\U0001f403", # 🐃 "\U0001f6be", # 🚾 "\U0001f52b", # 🔫 "\U0001f30a", # 🌊 "\U0001f349", # 🍉 "\U0001f44b", # 👋 "\U0001f44b\U0001f3ff", # 👋🏿 "\U0001f44b\U0001f3fb", # 👋🏻 "\U0001f44b\U0001f3fe", # 👋🏾 "\U0001f44b\U0001f3fc", # 👋🏼 "\U0001f44b\U0001f3fd", # 👋🏽 "\U00003030\U0000fe0f", # 〰️ "\U00003030", # 〰 "\U0001f312", # 🌒 "\U0001f314", # 🌔 "\U0001f640", # 🙀 "\U0001f629", # 😩 "\U0001f492", # 💒 "\U0001f40b", # 🐋 "\U0001f6de", # 🛞 "\U00002638\U0000fe0f", # ☸️ "\U00002638", # ☸ "\U0000267f", # ♿ "\U0001f9af", # 🦯 "\U000026aa", # ⚪ "\U00002755", # ❕ "\U0001f3f3\U0000fe0f", # 🏳️ "\U0001f3f3", # 🏳 "\U0001f4ae", # 💮 "\U0001f9b3", # 🦳 "\U0001f90d", # 🤍 "\U00002b1c", # ⬜ "\U000025fd", # ◽ "\U000025fb\U0000fe0f", # ◻️ "\U000025fb", # ◻ "\U00002754", # ❔ "\U000025ab\U0000fe0f", # ▫️ "\U000025ab", # ▫ "\U0001f533", # 🔳 "\U0001f940", # 🥀 "\U0001f390", # 🎐 "\U0001f32c\U0000fe0f", # 🌬️ "\U0001f32c", # 🌬 "\U0001fa9f", # 🪟 "\U0001f377", # 🍷 "\U0001fabd", # 🪽 "\U0001f609", # 😉 "\U0001f61c", # 😜 "\U0001f6dc", # 🛜 "\U0001f43a", # 🐺 "\U0001f469", # 👩 "\U0001f46b", # 👫 "\U0001f46b\U0001f3ff", # 👫🏿 "\U0001f469\U0001f3ff\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fb", # 👩🏿‍🤝‍👨🏻 "\U0001f469\U0001f3ff\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fe", # 👩🏿‍🤝‍👨🏾 "\U0001f469\U0001f3ff\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fc", # 👩🏿‍🤝‍👨🏼 "\U0001f469\U0001f3ff\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fd", # 👩🏿‍🤝‍👨🏽 "\U0001f46b\U0001f3fb", # 👫🏻 "\U0001f469\U0001f3fb\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3ff", # 👩🏻‍🤝‍👨🏿 "\U0001f469\U0001f3fb\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fe", # 👩🏻‍🤝‍👨🏾 "\U0001f469\U0001f3fb\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fc", # 👩🏻‍🤝‍👨🏼 "\U0001f469\U0001f3fb\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fd", # 👩🏻‍🤝‍👨🏽 "\U0001f46b\U0001f3fe", # 👫🏾 "\U0001f469\U0001f3fe\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3ff", # 👩🏾‍🤝‍👨🏿 "\U0001f469\U0001f3fe\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fb", # 👩🏾‍🤝‍👨🏻 "\U0001f469\U0001f3fe\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fc", # 👩🏾‍🤝‍👨🏼 "\U0001f469\U0001f3fe\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fd", # 👩🏾‍🤝‍👨🏽 "\U0001f46b\U0001f3fc", # 👫🏼 "\U0001f469\U0001f3fc\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3ff", # 👩🏼‍🤝‍👨🏿 "\U0001f469\U0001f3fc\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fb", # 👩🏼‍🤝‍👨🏻 "\U0001f469\U0001f3fc\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fe", # 👩🏼‍🤝‍👨🏾 "\U0001f469\U0001f3fc\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fd", # 👩🏼‍🤝‍👨🏽 "\U0001f46b\U0001f3fd", # 👫🏽 "\U0001f469\U0001f3fd\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3ff", # 👩🏽‍🤝‍👨🏿 "\U0001f469\U0001f3fd\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fb", # 👩🏽‍🤝‍👨🏻 "\U0001f469\U0001f3fd\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fe", # 👩🏽‍🤝‍👨🏾 "\U0001f469\U0001f3fd\U0000200d\U0001f91d\U0000200d\U0001f468\U0001f3fc", # 👩🏽‍🤝‍👨🏼 "\U0001f469\U0000200d\U0001f3a8", # 👩‍🎨 "\U0001f469\U0001f3ff\U0000200d\U0001f3a8", # 👩🏿‍🎨 "\U0001f469\U0001f3fb\U0000200d\U0001f3a8", # 👩🏻‍🎨 "\U0001f469\U0001f3fe\U0000200d\U0001f3a8", # 👩🏾‍🎨 "\U0001f469\U0001f3fc\U0000200d\U0001f3a8", # 👩🏼‍🎨 "\U0001f469\U0001f3fd\U0000200d\U0001f3a8", # 👩🏽‍🎨 "\U0001f469\U0000200d\U0001f680", # 👩‍🚀 "\U0001f469\U0001f3ff\U0000200d\U0001f680", # 👩🏿‍🚀 "\U0001f469\U0001f3fb\U0000200d\U0001f680", # 👩🏻‍🚀 "\U0001f469\U0001f3fe\U0000200d\U0001f680", # 👩🏾‍🚀 "\U0001f469\U0001f3fc\U0000200d\U0001f680", # 👩🏼‍🚀 "\U0001f469\U0001f3fd\U0000200d\U0001f680", # 👩🏽‍🚀 "\U0001f469\U0000200d\U0001f9b2", # 👩‍🦲 "\U0001f9d4\U0000200d\U00002640\U0000fe0f", # 🧔‍♀️ "\U0001f9d4\U0000200d\U00002640", # 🧔‍♀ "\U0001f6b4\U0000200d\U00002640\U0000fe0f", # 🚴‍♀️ "\U0001f6b4\U0000200d\U00002640", # 🚴‍♀ "\U0001f6b4\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🚴🏿‍♀️ "\U0001f6b4\U0001f3ff\U0000200d\U00002640", # 🚴🏿‍♀ "\U0001f6b4\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🚴🏻‍♀️ "\U0001f6b4\U0001f3fb\U0000200d\U00002640", # 🚴🏻‍♀ "\U0001f6b4\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🚴🏾‍♀️ "\U0001f6b4\U0001f3fe\U0000200d\U00002640", # 🚴🏾‍♀ "\U0001f6b4\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🚴🏼‍♀️ "\U0001f6b4\U0001f3fc\U0000200d\U00002640", # 🚴🏼‍♀ "\U0001f6b4\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🚴🏽‍♀️ "\U0001f6b4\U0001f3fd\U0000200d\U00002640", # 🚴🏽‍♀ "\U0001f471\U0000200d\U00002640\U0000fe0f", # 👱‍♀️ "\U0001f471\U0000200d\U00002640", # 👱‍♀ "\U000026f9\U0000fe0f\U0000200d\U00002640\U0000fe0f", # ⛹️‍♀️ "\U000026f9\U0000200d\U00002640\U0000fe0f", # ⛹‍♀️ "\U000026f9\U0000fe0f\U0000200d\U00002640", # ⛹️‍♀ "\U000026f9\U0000200d\U00002640", # ⛹‍♀ "\U000026f9\U0001f3ff\U0000200d\U00002640\U0000fe0f", # ⛹🏿‍♀️ "\U000026f9\U0001f3ff\U0000200d\U00002640", # ⛹🏿‍♀ "\U000026f9\U0001f3fb\U0000200d\U00002640\U0000fe0f", # ⛹🏻‍♀️ "\U000026f9\U0001f3fb\U0000200d\U00002640", # ⛹🏻‍♀ "\U000026f9\U0001f3fe\U0000200d\U00002640\U0000fe0f", # ⛹🏾‍♀️ "\U000026f9\U0001f3fe\U0000200d\U00002640", # ⛹🏾‍♀ "\U000026f9\U0001f3fc\U0000200d\U00002640\U0000fe0f", # ⛹🏼‍♀️ "\U000026f9\U0001f3fc\U0000200d\U00002640", # ⛹🏼‍♀ "\U000026f9\U0001f3fd\U0000200d\U00002640\U0000fe0f", # ⛹🏽‍♀️ "\U000026f9\U0001f3fd\U0000200d\U00002640", # ⛹🏽‍♀ "\U0001f647\U0000200d\U00002640\U0000fe0f", # 🙇‍♀️ "\U0001f647\U0000200d\U00002640", # 🙇‍♀ "\U0001f647\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🙇🏿‍♀️ "\U0001f647\U0001f3ff\U0000200d\U00002640", # 🙇🏿‍♀ "\U0001f647\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🙇🏻‍♀️ "\U0001f647\U0001f3fb\U0000200d\U00002640", # 🙇🏻‍♀ "\U0001f647\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🙇🏾‍♀️ "\U0001f647\U0001f3fe\U0000200d\U00002640", # 🙇🏾‍♀ "\U0001f647\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🙇🏼‍♀️ "\U0001f647\U0001f3fc\U0000200d\U00002640", # 🙇🏼‍♀ "\U0001f647\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🙇🏽‍♀️ "\U0001f647\U0001f3fd\U0000200d\U00002640", # 🙇🏽‍♀ "\U0001f938\U0000200d\U00002640\U0000fe0f", # 🤸‍♀️ "\U0001f938\U0000200d\U00002640", # 🤸‍♀ "\U0001f938\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🤸🏿‍♀️ "\U0001f938\U0001f3ff\U0000200d\U00002640", # 🤸🏿‍♀ "\U0001f938\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🤸🏻‍♀️ "\U0001f938\U0001f3fb\U0000200d\U00002640", # 🤸🏻‍♀ "\U0001f938\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🤸🏾‍♀️ "\U0001f938\U0001f3fe\U0000200d\U00002640", # 🤸🏾‍♀ "\U0001f938\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🤸🏼‍♀️ "\U0001f938\U0001f3fc\U0000200d\U00002640", # 🤸🏼‍♀ "\U0001f938\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🤸🏽‍♀️ "\U0001f938\U0001f3fd\U0000200d\U00002640", # 🤸🏽‍♀ "\U0001f9d7\U0000200d\U00002640\U0000fe0f", # 🧗‍♀️ "\U0001f9d7\U0000200d\U00002640", # 🧗‍♀ "\U0001f9d7\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🧗🏿‍♀️ "\U0001f9d7\U0001f3ff\U0000200d\U00002640", # 🧗🏿‍♀ "\U0001f9d7\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🧗🏻‍♀️ "\U0001f9d7\U0001f3fb\U0000200d\U00002640", # 🧗🏻‍♀ "\U0001f9d7\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🧗🏾‍♀️ "\U0001f9d7\U0001f3fe\U0000200d\U00002640", # 🧗🏾‍♀ "\U0001f9d7\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🧗🏼‍♀️ "\U0001f9d7\U0001f3fc\U0000200d\U00002640", # 🧗🏼‍♀ "\U0001f9d7\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🧗🏽‍♀️ "\U0001f9d7\U0001f3fd\U0000200d\U00002640", # 🧗🏽‍♀ "\U0001f477\U0000200d\U00002640\U0000fe0f", # 👷‍♀️ "\U0001f477\U0000200d\U00002640", # 👷‍♀ "\U0001f477\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 👷🏿‍♀️ "\U0001f477\U0001f3ff\U0000200d\U00002640", # 👷🏿‍♀ "\U0001f477\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 👷🏻‍♀️ "\U0001f477\U0001f3fb\U0000200d\U00002640", # 👷🏻‍♀ "\U0001f477\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 👷🏾‍♀️ "\U0001f477\U0001f3fe\U0000200d\U00002640", # 👷🏾‍♀ "\U0001f477\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 👷🏼‍♀️ "\U0001f477\U0001f3fc\U0000200d\U00002640", # 👷🏼‍♀ "\U0001f477\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 👷🏽‍♀️ "\U0001f477\U0001f3fd\U0000200d\U00002640", # 👷🏽‍♀ "\U0001f469\U0000200d\U0001f373", # 👩‍🍳 "\U0001f469\U0001f3ff\U0000200d\U0001f373", # 👩🏿‍🍳 "\U0001f469\U0001f3fb\U0000200d\U0001f373", # 👩🏻‍🍳 "\U0001f469\U0001f3fe\U0000200d\U0001f373", # 👩🏾‍🍳 "\U0001f469\U0001f3fc\U0000200d\U0001f373", # 👩🏼‍🍳 "\U0001f469\U0001f3fd\U0000200d\U0001f373", # 👩🏽‍🍳 "\U0001f469\U0000200d\U0001f9b1", # 👩‍🦱 "\U0001f483", # 💃 "\U0001f483\U0001f3ff", # 💃🏿 "\U0001f483\U0001f3fb", # 💃🏻 "\U0001f483\U0001f3fe", # 💃🏾 "\U0001f483\U0001f3fc", # 💃🏼 "\U0001f483\U0001f3fd", # 💃🏽 "\U0001f469\U0001f3ff", # 👩🏿 "\U0001f469\U0001f3ff\U0000200d\U0001f9b2", # 👩🏿‍🦲 "\U0001f9d4\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🧔🏿‍♀️ "\U0001f9d4\U0001f3ff\U0000200d\U00002640", # 🧔🏿‍♀ "\U0001f471\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 👱🏿‍♀️ "\U0001f471\U0001f3ff\U0000200d\U00002640", # 👱🏿‍♀ "\U0001f469\U0001f3ff\U0000200d\U0001f9b1", # 👩🏿‍🦱 "\U0001f469\U0001f3ff\U0000200d\U0001f9b0", # 👩🏿‍🦰 "\U0001f469\U0001f3ff\U0000200d\U0001f9b3", # 👩🏿‍🦳 "\U0001f575\U0000fe0f\U0000200d\U00002640\U0000fe0f", # 🕵️‍♀️ "\U0001f575\U0000200d\U00002640\U0000fe0f", # 🕵‍♀️ "\U0001f575\U0000fe0f\U0000200d\U00002640", # 🕵️‍♀ "\U0001f575\U0000200d\U00002640", # 🕵‍♀ "\U0001f575\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🕵🏿‍♀️ "\U0001f575\U0001f3ff\U0000200d\U00002640", # 🕵🏿‍♀ "\U0001f575\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🕵🏻‍♀️ "\U0001f575\U0001f3fb\U0000200d\U00002640", # 🕵🏻‍♀ "\U0001f575\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🕵🏾‍♀️ "\U0001f575\U0001f3fe\U0000200d\U00002640", # 🕵🏾‍♀ "\U0001f575\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🕵🏼‍♀️ "\U0001f575\U0001f3fc\U0000200d\U00002640", # 🕵🏼‍♀ "\U0001f575\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🕵🏽‍♀️ "\U0001f575\U0001f3fd\U0000200d\U00002640", # 🕵🏽‍♀ "\U0001f9dd\U0000200d\U00002640\U0000fe0f", # 🧝‍♀️ "\U0001f9dd\U0000200d\U00002640", # 🧝‍♀ "\U0001f9dd\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🧝🏿‍♀️ "\U0001f9dd\U0001f3ff\U0000200d\U00002640", # 🧝🏿‍♀ "\U0001f9dd\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🧝🏻‍♀️ "\U0001f9dd\U0001f3fb\U0000200d\U00002640", # 🧝🏻‍♀ "\U0001f9dd\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🧝🏾‍♀️ "\U0001f9dd\U0001f3fe\U0000200d\U00002640", # 🧝🏾‍♀ "\U0001f9dd\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🧝🏼‍♀️ "\U0001f9dd\U0001f3fc\U0000200d\U00002640", # 🧝🏼‍♀ "\U0001f9dd\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🧝🏽‍♀️ "\U0001f9dd\U0001f3fd\U0000200d\U00002640", # 🧝🏽‍♀ "\U0001f926\U0000200d\U00002640\U0000fe0f", # 🤦‍♀️ "\U0001f926\U0000200d\U00002640", # 🤦‍♀ "\U0001f926\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🤦🏿‍♀️ "\U0001f926\U0001f3ff\U0000200d\U00002640", # 🤦🏿‍♀ "\U0001f926\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🤦🏻‍♀️ "\U0001f926\U0001f3fb\U0000200d\U00002640", # 🤦🏻‍♀ "\U0001f926\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🤦🏾‍♀️ "\U0001f926\U0001f3fe\U0000200d\U00002640", # 🤦🏾‍♀ "\U0001f926\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🤦🏼‍♀️ "\U0001f926\U0001f3fc\U0000200d\U00002640", # 🤦🏼‍♀ "\U0001f926\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🤦🏽‍♀️ "\U0001f926\U0001f3fd\U0000200d\U00002640", # 🤦🏽‍♀ "\U0001f469\U0000200d\U0001f3ed", # 👩‍🏭 "\U0001f469\U0001f3ff\U0000200d\U0001f3ed", # 👩🏿‍🏭 "\U0001f469\U0001f3fb\U0000200d\U0001f3ed", # 👩🏻‍🏭 "\U0001f469\U0001f3fe\U0000200d\U0001f3ed", # 👩🏾‍🏭 "\U0001f469\U0001f3fc\U0000200d\U0001f3ed", # 👩🏼‍🏭 "\U0001f469\U0001f3fd\U0000200d\U0001f3ed", # 👩🏽‍🏭 "\U0001f9da\U0000200d\U00002640\U0000fe0f", # 🧚‍♀️ "\U0001f9da\U0000200d\U00002640", # 🧚‍♀ "\U0001f9da\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🧚🏿‍♀️ "\U0001f9da\U0001f3ff\U0000200d\U00002640", # 🧚🏿‍♀ "\U0001f9da\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🧚🏻‍♀️ "\U0001f9da\U0001f3fb\U0000200d\U00002640", # 🧚🏻‍♀ "\U0001f9da\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🧚🏾‍♀️ "\U0001f9da\U0001f3fe\U0000200d\U00002640", # 🧚🏾‍♀ "\U0001f9da\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🧚🏼‍♀️ "\U0001f9da\U0001f3fc\U0000200d\U00002640", # 🧚🏼‍♀ "\U0001f9da\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🧚🏽‍♀️ "\U0001f9da\U0001f3fd\U0000200d\U00002640", # 🧚🏽‍♀ "\U0001f469\U0000200d\U0001f33e", # 👩‍🌾 "\U0001f469\U0001f3ff\U0000200d\U0001f33e", # 👩🏿‍🌾 "\U0001f469\U0001f3fb\U0000200d\U0001f33e", # 👩🏻‍🌾 "\U0001f469\U0001f3fe\U0000200d\U0001f33e", # 👩🏾‍🌾 "\U0001f469\U0001f3fc\U0000200d\U0001f33e", # 👩🏼‍🌾 "\U0001f469\U0001f3fd\U0000200d\U0001f33e", # 👩🏽‍🌾 "\U0001f469\U0000200d\U0001f37c", # 👩‍🍼 "\U0001f469\U0001f3ff\U0000200d\U0001f37c", # 👩🏿‍🍼 "\U0001f469\U0001f3fb\U0000200d\U0001f37c", # 👩🏻‍🍼 "\U0001f469\U0001f3fe\U0000200d\U0001f37c", # 👩🏾‍🍼 "\U0001f469\U0001f3fc\U0000200d\U0001f37c", # 👩🏼‍🍼 "\U0001f469\U0001f3fd\U0000200d\U0001f37c", # 👩🏽‍🍼 "\U0001f469\U0000200d\U0001f692", # 👩‍🚒 "\U0001f469\U0001f3ff\U0000200d\U0001f692", # 👩🏿‍🚒 "\U0001f469\U0001f3fb\U0000200d\U0001f692", # 👩🏻‍🚒 "\U0001f469\U0001f3fe\U0000200d\U0001f692", # 👩🏾‍🚒 "\U0001f469\U0001f3fc\U0000200d\U0001f692", # 👩🏼‍🚒 "\U0001f469\U0001f3fd\U0000200d\U0001f692", # 👩🏽‍🚒 "\U0001f64d\U0000200d\U00002640\U0000fe0f", # 🙍‍♀️ "\U0001f64d\U0000200d\U00002640", # 🙍‍♀ "\U0001f64d\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🙍🏿‍♀️ "\U0001f64d\U0001f3ff\U0000200d\U00002640", # 🙍🏿‍♀ "\U0001f64d\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🙍🏻‍♀️ "\U0001f64d\U0001f3fb\U0000200d\U00002640", # 🙍🏻‍♀ "\U0001f64d\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🙍🏾‍♀️ "\U0001f64d\U0001f3fe\U0000200d\U00002640", # 🙍🏾‍♀ "\U0001f64d\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🙍🏼‍♀️ "\U0001f64d\U0001f3fc\U0000200d\U00002640", # 🙍🏼‍♀ "\U0001f64d\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🙍🏽‍♀️ "\U0001f64d\U0001f3fd\U0000200d\U00002640", # 🙍🏽‍♀ "\U0001f9de\U0000200d\U00002640\U0000fe0f", # 🧞‍♀️ "\U0001f9de\U0000200d\U00002640", # 🧞‍♀ "\U0001f645\U0000200d\U00002640\U0000fe0f", # 🙅‍♀️ "\U0001f645\U0000200d\U00002640", # 🙅‍♀ "\U0001f645\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🙅🏿‍♀️ "\U0001f645\U0001f3ff\U0000200d\U00002640", # 🙅🏿‍♀ "\U0001f645\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🙅🏻‍♀️ "\U0001f645\U0001f3fb\U0000200d\U00002640", # 🙅🏻‍♀ "\U0001f645\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🙅🏾‍♀️ "\U0001f645\U0001f3fe\U0000200d\U00002640", # 🙅🏾‍♀ "\U0001f645\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🙅🏼‍♀️ "\U0001f645\U0001f3fc\U0000200d\U00002640", # 🙅🏼‍♀ "\U0001f645\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🙅🏽‍♀️ "\U0001f645\U0001f3fd\U0000200d\U00002640", # 🙅🏽‍♀ "\U0001f646\U0000200d\U00002640\U0000fe0f", # 🙆‍♀️ "\U0001f646\U0000200d\U00002640", # 🙆‍♀ "\U0001f646\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🙆🏿‍♀️ "\U0001f646\U0001f3ff\U0000200d\U00002640", # 🙆🏿‍♀ "\U0001f646\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🙆🏻‍♀️ "\U0001f646\U0001f3fb\U0000200d\U00002640", # 🙆🏻‍♀ "\U0001f646\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🙆🏾‍♀️ "\U0001f646\U0001f3fe\U0000200d\U00002640", # 🙆🏾‍♀ "\U0001f646\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🙆🏼‍♀️ "\U0001f646\U0001f3fc\U0000200d\U00002640", # 🙆🏼‍♀ "\U0001f646\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🙆🏽‍♀️ "\U0001f646\U0001f3fd\U0000200d\U00002640", # 🙆🏽‍♀ "\U0001f487\U0000200d\U00002640\U0000fe0f", # 💇‍♀️ "\U0001f487\U0000200d\U00002640", # 💇‍♀ "\U0001f487\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 💇🏿‍♀️ "\U0001f487\U0001f3ff\U0000200d\U00002640", # 💇🏿‍♀ "\U0001f487\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 💇🏻‍♀️ "\U0001f487\U0001f3fb\U0000200d\U00002640", # 💇🏻‍♀ "\U0001f487\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 💇🏾‍♀️ "\U0001f487\U0001f3fe\U0000200d\U00002640", # 💇🏾‍♀ "\U0001f487\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 💇🏼‍♀️ "\U0001f487\U0001f3fc\U0000200d\U00002640", # 💇🏼‍♀ "\U0001f487\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 💇🏽‍♀️ "\U0001f487\U0001f3fd\U0000200d\U00002640", # 💇🏽‍♀ "\U0001f486\U0000200d\U00002640\U0000fe0f", # 💆‍♀️ "\U0001f486\U0000200d\U00002640", # 💆‍♀ "\U0001f486\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 💆🏿‍♀️ "\U0001f486\U0001f3ff\U0000200d\U00002640", # 💆🏿‍♀ "\U0001f486\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 💆🏻‍♀️ "\U0001f486\U0001f3fb\U0000200d\U00002640", # 💆🏻‍♀ "\U0001f486\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 💆🏾‍♀️ "\U0001f486\U0001f3fe\U0000200d\U00002640", # 💆🏾‍♀ "\U0001f486\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 💆🏼‍♀️ "\U0001f486\U0001f3fc\U0000200d\U00002640", # 💆🏼‍♀ "\U0001f486\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 💆🏽‍♀️ "\U0001f486\U0001f3fd\U0000200d\U00002640", # 💆🏽‍♀ "\U0001f3cc\U0000fe0f\U0000200d\U00002640\U0000fe0f", # 🏌️‍♀️ "\U0001f3cc\U0000200d\U00002640\U0000fe0f", # 🏌‍♀️ "\U0001f3cc\U0000fe0f\U0000200d\U00002640", # 🏌️‍♀ "\U0001f3cc\U0000200d\U00002640", # 🏌‍♀ "\U0001f3cc\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🏌🏿‍♀️ "\U0001f3cc\U0001f3ff\U0000200d\U00002640", # 🏌🏿‍♀ "\U0001f3cc\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🏌🏻‍♀️ "\U0001f3cc\U0001f3fb\U0000200d\U00002640", # 🏌🏻‍♀ "\U0001f3cc\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🏌🏾‍♀️ "\U0001f3cc\U0001f3fe\U0000200d\U00002640", # 🏌🏾‍♀ "\U0001f3cc\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🏌🏼‍♀️ "\U0001f3cc\U0001f3fc\U0000200d\U00002640", # 🏌🏼‍♀ "\U0001f3cc\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🏌🏽‍♀️ "\U0001f3cc\U0001f3fd\U0000200d\U00002640", # 🏌🏽‍♀ "\U0001f482\U0000200d\U00002640\U0000fe0f", # 💂‍♀️ "\U0001f482\U0000200d\U00002640", # 💂‍♀ "\U0001f482\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 💂🏿‍♀️ "\U0001f482\U0001f3ff\U0000200d\U00002640", # 💂🏿‍♀ "\U0001f482\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 💂🏻‍♀️ "\U0001f482\U0001f3fb\U0000200d\U00002640", # 💂🏻‍♀ "\U0001f482\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 💂🏾‍♀️ "\U0001f482\U0001f3fe\U0000200d\U00002640", # 💂🏾‍♀ "\U0001f482\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 💂🏼‍♀️ "\U0001f482\U0001f3fc\U0000200d\U00002640", # 💂🏼‍♀ "\U0001f482\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 💂🏽‍♀️ "\U0001f482\U0001f3fd\U0000200d\U00002640", # 💂🏽‍♀ "\U0001f469\U0000200d\U00002695\U0000fe0f", # 👩‍⚕️ "\U0001f469\U0000200d\U00002695", # 👩‍⚕ "\U0001f469\U0001f3ff\U0000200d\U00002695\U0000fe0f", # 👩🏿‍⚕️ "\U0001f469\U0001f3ff\U0000200d\U00002695", # 👩🏿‍⚕ "\U0001f469\U0001f3fb\U0000200d\U00002695\U0000fe0f", # 👩🏻‍⚕️ "\U0001f469\U0001f3fb\U0000200d\U00002695", # 👩🏻‍⚕ "\U0001f469\U0001f3fe\U0000200d\U00002695\U0000fe0f", # 👩🏾‍⚕️ "\U0001f469\U0001f3fe\U0000200d\U00002695", # 👩🏾‍⚕ "\U0001f469\U0001f3fc\U0000200d\U00002695\U0000fe0f", # 👩🏼‍⚕️ "\U0001f469\U0001f3fc\U0000200d\U00002695", # 👩🏼‍⚕ "\U0001f469\U0001f3fd\U0000200d\U00002695\U0000fe0f", # 👩🏽‍⚕️ "\U0001f469\U0001f3fd\U0000200d\U00002695", # 👩🏽‍⚕ "\U0001f9d8\U0000200d\U00002640\U0000fe0f", # 🧘‍♀️ "\U0001f9d8\U0000200d\U00002640", # 🧘‍♀ "\U0001f9d8\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🧘🏿‍♀️ "\U0001f9d8\U0001f3ff\U0000200d\U00002640", # 🧘🏿‍♀ "\U0001f9d8\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🧘🏻‍♀️ "\U0001f9d8\U0001f3fb\U0000200d\U00002640", # 🧘🏻‍♀ "\U0001f9d8\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🧘🏾‍♀️ "\U0001f9d8\U0001f3fe\U0000200d\U00002640", # 🧘🏾‍♀ "\U0001f9d8\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🧘🏼‍♀️ "\U0001f9d8\U0001f3fc\U0000200d\U00002640", # 🧘🏼‍♀ "\U0001f9d8\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🧘🏽‍♀️ "\U0001f9d8\U0001f3fd\U0000200d\U00002640", # 🧘🏽‍♀ "\U0001f469\U0000200d\U0001f9bd", # 👩‍🦽 "\U0001f469\U0001f3ff\U0000200d\U0001f9bd", # 👩🏿‍🦽 "\U0001f469\U0000200d\U0001f9bd\U0000200d\U000027a1\U0000fe0f", # 👩‍🦽‍➡️ "\U0001f469\U0000200d\U0001f9bd\U0000200d\U000027a1", # 👩‍🦽‍➡ "\U0001f469\U0001f3ff\U0000200d\U0001f9bd\U0000200d\U000027a1\U0000fe0f", # 👩🏿‍🦽‍➡️ "\U0001f469\U0001f3ff\U0000200d\U0001f9bd\U0000200d\U000027a1", # 👩🏿‍🦽‍➡ "\U0001f469\U0001f3fb\U0000200d\U0001f9bd\U0000200d\U000027a1\U0000fe0f", # 👩🏻‍🦽‍➡️ "\U0001f469\U0001f3fb\U0000200d\U0001f9bd\U0000200d\U000027a1", # 👩🏻‍🦽‍➡ "\U0001f469\U0001f3fe\U0000200d\U0001f9bd\U0000200d\U000027a1\U0000fe0f", # 👩🏾‍🦽‍➡️ "\U0001f469\U0001f3fe\U0000200d\U0001f9bd\U0000200d\U000027a1", # 👩🏾‍🦽‍➡ "\U0001f469\U0001f3fc\U0000200d\U0001f9bd\U0000200d\U000027a1\U0000fe0f", # 👩🏼‍🦽‍➡️ "\U0001f469\U0001f3fc\U0000200d\U0001f9bd\U0000200d\U000027a1", # 👩🏼‍🦽‍➡ "\U0001f469\U0001f3fd\U0000200d\U0001f9bd\U0000200d\U000027a1\U0000fe0f", # 👩🏽‍🦽‍➡️ "\U0001f469\U0001f3fd\U0000200d\U0001f9bd\U0000200d\U000027a1", # 👩🏽‍🦽‍➡ "\U0001f469\U0001f3fb\U0000200d\U0001f9bd", # 👩🏻‍🦽 "\U0001f469\U0001f3fe\U0000200d\U0001f9bd", # 👩🏾‍🦽 "\U0001f469\U0001f3fc\U0000200d\U0001f9bd", # 👩🏼‍🦽 "\U0001f469\U0001f3fd\U0000200d\U0001f9bd", # 👩🏽‍🦽 "\U0001f469\U0000200d\U0001f9bc", # 👩‍🦼 "\U0001f469\U0001f3ff\U0000200d\U0001f9bc", # 👩🏿‍🦼 "\U0001f469\U0000200d\U0001f9bc\U0000200d\U000027a1\U0000fe0f", # 👩‍🦼‍➡️ "\U0001f469\U0000200d\U0001f9bc\U0000200d\U000027a1", # 👩‍🦼‍➡ "\U0001f469\U0001f3ff\U0000200d\U0001f9bc\U0000200d\U000027a1\U0000fe0f", # 👩🏿‍🦼‍➡️ "\U0001f469\U0001f3ff\U0000200d\U0001f9bc\U0000200d\U000027a1", # 👩🏿‍🦼‍➡ "\U0001f469\U0001f3fb\U0000200d\U0001f9bc\U0000200d\U000027a1\U0000fe0f", # 👩🏻‍🦼‍➡️ "\U0001f469\U0001f3fb\U0000200d\U0001f9bc\U0000200d\U000027a1", # 👩🏻‍🦼‍➡ "\U0001f469\U0001f3fe\U0000200d\U0001f9bc\U0000200d\U000027a1\U0000fe0f", # 👩🏾‍🦼‍➡️ "\U0001f469\U0001f3fe\U0000200d\U0001f9bc\U0000200d\U000027a1", # 👩🏾‍🦼‍➡ "\U0001f469\U0001f3fc\U0000200d\U0001f9bc\U0000200d\U000027a1\U0000fe0f", # 👩🏼‍🦼‍➡️ "\U0001f469\U0001f3fc\U0000200d\U0001f9bc\U0000200d\U000027a1", # 👩🏼‍🦼‍➡ "\U0001f469\U0001f3fd\U0000200d\U0001f9bc\U0000200d\U000027a1\U0000fe0f", # 👩🏽‍🦼‍➡️ "\U0001f469\U0001f3fd\U0000200d\U0001f9bc\U0000200d\U000027a1", # 👩🏽‍🦼‍➡ "\U0001f469\U0001f3fb\U0000200d\U0001f9bc", # 👩🏻‍🦼 "\U0001f469\U0001f3fe\U0000200d\U0001f9bc", # 👩🏾‍🦼 "\U0001f469\U0001f3fc\U0000200d\U0001f9bc", # 👩🏼‍🦼 "\U0001f469\U0001f3fd\U0000200d\U0001f9bc", # 👩🏽‍🦼 "\U0001f9d6\U0000200d\U00002640\U0000fe0f", # 🧖‍♀️ "\U0001f9d6\U0000200d\U00002640", # 🧖‍♀ "\U0001f9d6\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🧖🏿‍♀️ "\U0001f9d6\U0001f3ff\U0000200d\U00002640", # 🧖🏿‍♀ "\U0001f9d6\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🧖🏻‍♀️ "\U0001f9d6\U0001f3fb\U0000200d\U00002640", # 🧖🏻‍♀ "\U0001f9d6\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🧖🏾‍♀️ "\U0001f9d6\U0001f3fe\U0000200d\U00002640", # 🧖🏾‍♀ "\U0001f9d6\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🧖🏼‍♀️ "\U0001f9d6\U0001f3fc\U0000200d\U00002640", # 🧖🏼‍♀ "\U0001f9d6\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🧖🏽‍♀️ "\U0001f9d6\U0001f3fd\U0000200d\U00002640", # 🧖🏽‍♀ "\U0001f935\U0000200d\U00002640\U0000fe0f", # 🤵‍♀️ "\U0001f935\U0000200d\U00002640", # 🤵‍♀ "\U0001f935\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🤵🏿‍♀️ "\U0001f935\U0001f3ff\U0000200d\U00002640", # 🤵🏿‍♀ "\U0001f935\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🤵🏻‍♀️ "\U0001f935\U0001f3fb\U0000200d\U00002640", # 🤵🏻‍♀ "\U0001f935\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🤵🏾‍♀️ "\U0001f935\U0001f3fe\U0000200d\U00002640", # 🤵🏾‍♀ "\U0001f935\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🤵🏼‍♀️ "\U0001f935\U0001f3fc\U0000200d\U00002640", # 🤵🏼‍♀ "\U0001f935\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🤵🏽‍♀️ "\U0001f935\U0001f3fd\U0000200d\U00002640", # 🤵🏽‍♀ "\U0001f469\U0000200d\U00002696\U0000fe0f", # 👩‍⚖️ "\U0001f469\U0000200d\U00002696", # 👩‍⚖ "\U0001f469\U0001f3ff\U0000200d\U00002696\U0000fe0f", # 👩🏿‍⚖️ "\U0001f469\U0001f3ff\U0000200d\U00002696", # 👩🏿‍⚖ "\U0001f469\U0001f3fb\U0000200d\U00002696\U0000fe0f", # 👩🏻‍⚖️ "\U0001f469\U0001f3fb\U0000200d\U00002696", # 👩🏻‍⚖ "\U0001f469\U0001f3fe\U0000200d\U00002696\U0000fe0f", # 👩🏾‍⚖️ "\U0001f469\U0001f3fe\U0000200d\U00002696", # 👩🏾‍⚖ "\U0001f469\U0001f3fc\U0000200d\U00002696\U0000fe0f", # 👩🏼‍⚖️ "\U0001f469\U0001f3fc\U0000200d\U00002696", # 👩🏼‍⚖ "\U0001f469\U0001f3fd\U0000200d\U00002696\U0000fe0f", # 👩🏽‍⚖️ "\U0001f469\U0001f3fd\U0000200d\U00002696", # 👩🏽‍⚖ "\U0001f939\U0000200d\U00002640\U0000fe0f", # 🤹‍♀️ "\U0001f939\U0000200d\U00002640", # 🤹‍♀ "\U0001f939\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🤹🏿‍♀️ "\U0001f939\U0001f3ff\U0000200d\U00002640", # 🤹🏿‍♀ "\U0001f939\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🤹🏻‍♀️ "\U0001f939\U0001f3fb\U0000200d\U00002640", # 🤹🏻‍♀ "\U0001f939\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🤹🏾‍♀️ "\U0001f939\U0001f3fe\U0000200d\U00002640", # 🤹🏾‍♀ "\U0001f939\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🤹🏼‍♀️ "\U0001f939\U0001f3fc\U0000200d\U00002640", # 🤹🏼‍♀ "\U0001f939\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🤹🏽‍♀️ "\U0001f939\U0001f3fd\U0000200d\U00002640", # 🤹🏽‍♀ "\U0001f9ce\U0000200d\U00002640\U0000fe0f", # 🧎‍♀️ "\U0001f9ce\U0000200d\U00002640", # 🧎‍♀ "\U0001f9ce\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🧎🏿‍♀️ "\U0001f9ce\U0001f3ff\U0000200d\U00002640", # 🧎🏿‍♀ "\U0001f9ce\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🧎‍♀️‍➡️ "\U0001f9ce\U0000200d\U00002640\U0000200d\U000027a1\U0000fe0f", # 🧎‍♀‍➡️ "\U0001f9ce\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1", # 🧎‍♀️‍➡ "\U0001f9ce\U0000200d\U00002640\U0000200d\U000027a1", # 🧎‍♀‍➡ "\U0001f9ce\U0001f3ff\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🧎🏿‍♀️‍➡️ "\U0001f9ce\U0001f3ff\U0000200d\U00002640\U0000200d\U000027a1\U0000fe0f", # 🧎🏿‍♀‍➡️ "\U0001f9ce\U0001f3ff\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1", # 🧎🏿‍♀️‍➡ "\U0001f9ce\U0001f3ff\U0000200d\U00002640\U0000200d\U000027a1", # 🧎🏿‍♀‍➡ "\U0001f9ce\U0001f3fb\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🧎🏻‍♀️‍➡️ "\U0001f9ce\U0001f3fb\U0000200d\U00002640\U0000200d\U000027a1\U0000fe0f", # 🧎🏻‍♀‍➡️ "\U0001f9ce\U0001f3fb\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1", # 🧎🏻‍♀️‍➡ "\U0001f9ce\U0001f3fb\U0000200d\U00002640\U0000200d\U000027a1", # 🧎🏻‍♀‍➡ "\U0001f9ce\U0001f3fe\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🧎🏾‍♀️‍➡️ "\U0001f9ce\U0001f3fe\U0000200d\U00002640\U0000200d\U000027a1\U0000fe0f", # 🧎🏾‍♀‍➡️ "\U0001f9ce\U0001f3fe\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1", # 🧎🏾‍♀️‍➡ "\U0001f9ce\U0001f3fe\U0000200d\U00002640\U0000200d\U000027a1", # 🧎🏾‍♀‍➡ "\U0001f9ce\U0001f3fc\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🧎🏼‍♀️‍➡️ "\U0001f9ce\U0001f3fc\U0000200d\U00002640\U0000200d\U000027a1\U0000fe0f", # 🧎🏼‍♀‍➡️ "\U0001f9ce\U0001f3fc\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1", # 🧎🏼‍♀️‍➡ "\U0001f9ce\U0001f3fc\U0000200d\U00002640\U0000200d\U000027a1", # 🧎🏼‍♀‍➡ "\U0001f9ce\U0001f3fd\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🧎🏽‍♀️‍➡️ "\U0001f9ce\U0001f3fd\U0000200d\U00002640\U0000200d\U000027a1\U0000fe0f", # 🧎🏽‍♀‍➡️ "\U0001f9ce\U0001f3fd\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1", # 🧎🏽‍♀️‍➡ "\U0001f9ce\U0001f3fd\U0000200d\U00002640\U0000200d\U000027a1", # 🧎🏽‍♀‍➡ "\U0001f9ce\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🧎🏻‍♀️ "\U0001f9ce\U0001f3fb\U0000200d\U00002640", # 🧎🏻‍♀ "\U0001f9ce\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🧎🏾‍♀️ "\U0001f9ce\U0001f3fe\U0000200d\U00002640", # 🧎🏾‍♀ "\U0001f9ce\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🧎🏼‍♀️ "\U0001f9ce\U0001f3fc\U0000200d\U00002640", # 🧎🏼‍♀ "\U0001f9ce\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🧎🏽‍♀️ "\U0001f9ce\U0001f3fd\U0000200d\U00002640", # 🧎🏽‍♀ "\U0001f3cb\U0000fe0f\U0000200d\U00002640\U0000fe0f", # 🏋️‍♀️ "\U0001f3cb\U0000200d\U00002640\U0000fe0f", # 🏋‍♀️ "\U0001f3cb\U0000fe0f\U0000200d\U00002640", # 🏋️‍♀ "\U0001f3cb\U0000200d\U00002640", # 🏋‍♀ "\U0001f3cb\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🏋🏿‍♀️ "\U0001f3cb\U0001f3ff\U0000200d\U00002640", # 🏋🏿‍♀ "\U0001f3cb\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🏋🏻‍♀️ "\U0001f3cb\U0001f3fb\U0000200d\U00002640", # 🏋🏻‍♀ "\U0001f3cb\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🏋🏾‍♀️ "\U0001f3cb\U0001f3fe\U0000200d\U00002640", # 🏋🏾‍♀ "\U0001f3cb\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🏋🏼‍♀️ "\U0001f3cb\U0001f3fc\U0000200d\U00002640", # 🏋🏼‍♀ "\U0001f3cb\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🏋🏽‍♀️ "\U0001f3cb\U0001f3fd\U0000200d\U00002640", # 🏋🏽‍♀ "\U0001f469\U0001f3fb", # 👩🏻 "\U0001f469\U0001f3fb\U0000200d\U0001f9b2", # 👩🏻‍🦲 "\U0001f9d4\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🧔🏻‍♀️ "\U0001f9d4\U0001f3fb\U0000200d\U00002640", # 🧔🏻‍♀ "\U0001f471\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 👱🏻‍♀️ "\U0001f471\U0001f3fb\U0000200d\U00002640", # 👱🏻‍♀ "\U0001f469\U0001f3fb\U0000200d\U0001f9b1", # 👩🏻‍🦱 "\U0001f469\U0001f3fb\U0000200d\U0001f9b0", # 👩🏻‍🦰 "\U0001f469\U0001f3fb\U0000200d\U0001f9b3", # 👩🏻‍🦳 "\U0001f9d9\U0000200d\U00002640\U0000fe0f", # 🧙‍♀️ "\U0001f9d9\U0000200d\U00002640", # 🧙‍♀ "\U0001f9d9\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🧙🏿‍♀️ "\U0001f9d9\U0001f3ff\U0000200d\U00002640", # 🧙🏿‍♀ "\U0001f9d9\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🧙🏻‍♀️ "\U0001f9d9\U0001f3fb\U0000200d\U00002640", # 🧙🏻‍♀ "\U0001f9d9\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🧙🏾‍♀️ "\U0001f9d9\U0001f3fe\U0000200d\U00002640", # 🧙🏾‍♀ "\U0001f9d9\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🧙🏼‍♀️ "\U0001f9d9\U0001f3fc\U0000200d\U00002640", # 🧙🏼‍♀ "\U0001f9d9\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🧙🏽‍♀️ "\U0001f9d9\U0001f3fd\U0000200d\U00002640", # 🧙🏽‍♀ "\U0001f469\U0000200d\U0001f527", # 👩‍🔧 "\U0001f469\U0001f3ff\U0000200d\U0001f527", # 👩🏿‍🔧 "\U0001f469\U0001f3fb\U0000200d\U0001f527", # 👩🏻‍🔧 "\U0001f469\U0001f3fe\U0000200d\U0001f527", # 👩🏾‍🔧 "\U0001f469\U0001f3fc\U0000200d\U0001f527", # 👩🏼‍🔧 "\U0001f469\U0001f3fd\U0000200d\U0001f527", # 👩🏽‍🔧 "\U0001f469\U0001f3fe", # 👩🏾 "\U0001f469\U0001f3fe\U0000200d\U0001f9b2", # 👩🏾‍🦲 "\U0001f9d4\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🧔🏾‍♀️ "\U0001f9d4\U0001f3fe\U0000200d\U00002640", # 🧔🏾‍♀ "\U0001f471\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 👱🏾‍♀️ "\U0001f471\U0001f3fe\U0000200d\U00002640", # 👱🏾‍♀ "\U0001f469\U0001f3fe\U0000200d\U0001f9b1", # 👩🏾‍🦱 "\U0001f469\U0001f3fe\U0000200d\U0001f9b0", # 👩🏾‍🦰 "\U0001f469\U0001f3fe\U0000200d\U0001f9b3", # 👩🏾‍🦳 "\U0001f469\U0001f3fc", # 👩🏼 "\U0001f469\U0001f3fc\U0000200d\U0001f9b2", # 👩🏼‍🦲 "\U0001f9d4\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🧔🏼‍♀️ "\U0001f9d4\U0001f3fc\U0000200d\U00002640", # 🧔🏼‍♀ "\U0001f471\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 👱🏼‍♀️ "\U0001f471\U0001f3fc\U0000200d\U00002640", # 👱🏼‍♀ "\U0001f469\U0001f3fc\U0000200d\U0001f9b1", # 👩🏼‍🦱 "\U0001f469\U0001f3fc\U0000200d\U0001f9b0", # 👩🏼‍🦰 "\U0001f469\U0001f3fc\U0000200d\U0001f9b3", # 👩🏼‍🦳 "\U0001f469\U0001f3fd", # 👩🏽 "\U0001f469\U0001f3fd\U0000200d\U0001f9b2", # 👩🏽‍🦲 "\U0001f9d4\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🧔🏽‍♀️ "\U0001f9d4\U0001f3fd\U0000200d\U00002640", # 🧔🏽‍♀ "\U0001f471\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 👱🏽‍♀️ "\U0001f471\U0001f3fd\U0000200d\U00002640", # 👱🏽‍♀ "\U0001f469\U0001f3fd\U0000200d\U0001f9b1", # 👩🏽‍🦱 "\U0001f469\U0001f3fd\U0000200d\U0001f9b0", # 👩🏽‍🦰 "\U0001f469\U0001f3fd\U0000200d\U0001f9b3", # 👩🏽‍🦳 "\U0001f6b5\U0000200d\U00002640\U0000fe0f", # 🚵‍♀️ "\U0001f6b5\U0000200d\U00002640", # 🚵‍♀ "\U0001f6b5\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🚵🏿‍♀️ "\U0001f6b5\U0001f3ff\U0000200d\U00002640", # 🚵🏿‍♀ "\U0001f6b5\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🚵🏻‍♀️ "\U0001f6b5\U0001f3fb\U0000200d\U00002640", # 🚵🏻‍♀ "\U0001f6b5\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🚵🏾‍♀️ "\U0001f6b5\U0001f3fe\U0000200d\U00002640", # 🚵🏾‍♀ "\U0001f6b5\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🚵🏼‍♀️ "\U0001f6b5\U0001f3fc\U0000200d\U00002640", # 🚵🏼‍♀ "\U0001f6b5\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🚵🏽‍♀️ "\U0001f6b5\U0001f3fd\U0000200d\U00002640", # 🚵🏽‍♀ "\U0001f469\U0000200d\U0001f4bc", # 👩‍💼 "\U0001f469\U0001f3ff\U0000200d\U0001f4bc", # 👩🏿‍💼 "\U0001f469\U0001f3fb\U0000200d\U0001f4bc", # 👩🏻‍💼 "\U0001f469\U0001f3fe\U0000200d\U0001f4bc", # 👩🏾‍💼 "\U0001f469\U0001f3fc\U0000200d\U0001f4bc", # 👩🏼‍💼 "\U0001f469\U0001f3fd\U0000200d\U0001f4bc", # 👩🏽‍💼 "\U0001f469\U0000200d\U00002708\U0000fe0f", # 👩‍✈️ "\U0001f469\U0000200d\U00002708", # 👩‍✈ "\U0001f469\U0001f3ff\U0000200d\U00002708\U0000fe0f", # 👩🏿‍✈️ "\U0001f469\U0001f3ff\U0000200d\U00002708", # 👩🏿‍✈ "\U0001f469\U0001f3fb\U0000200d\U00002708\U0000fe0f", # 👩🏻‍✈️ "\U0001f469\U0001f3fb\U0000200d\U00002708", # 👩🏻‍✈ "\U0001f469\U0001f3fe\U0000200d\U00002708\U0000fe0f", # 👩🏾‍✈️ "\U0001f469\U0001f3fe\U0000200d\U00002708", # 👩🏾‍✈ "\U0001f469\U0001f3fc\U0000200d\U00002708\U0000fe0f", # 👩🏼‍✈️ "\U0001f469\U0001f3fc\U0000200d\U00002708", # 👩🏼‍✈ "\U0001f469\U0001f3fd\U0000200d\U00002708\U0000fe0f", # 👩🏽‍✈️ "\U0001f469\U0001f3fd\U0000200d\U00002708", # 👩🏽‍✈ "\U0001f93e\U0000200d\U00002640\U0000fe0f", # 🤾‍♀️ "\U0001f93e\U0000200d\U00002640", # 🤾‍♀ "\U0001f93e\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🤾🏿‍♀️ "\U0001f93e\U0001f3ff\U0000200d\U00002640", # 🤾🏿‍♀ "\U0001f93e\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🤾🏻‍♀️ "\U0001f93e\U0001f3fb\U0000200d\U00002640", # 🤾🏻‍♀ "\U0001f93e\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🤾🏾‍♀️ "\U0001f93e\U0001f3fe\U0000200d\U00002640", # 🤾🏾‍♀ "\U0001f93e\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🤾🏼‍♀️ "\U0001f93e\U0001f3fc\U0000200d\U00002640", # 🤾🏼‍♀ "\U0001f93e\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🤾🏽‍♀️ "\U0001f93e\U0001f3fd\U0000200d\U00002640", # 🤾🏽‍♀ "\U0001f93d\U0000200d\U00002640\U0000fe0f", # 🤽‍♀️ "\U0001f93d\U0000200d\U00002640", # 🤽‍♀ "\U0001f93d\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🤽🏿‍♀️ "\U0001f93d\U0001f3ff\U0000200d\U00002640", # 🤽🏿‍♀ "\U0001f93d\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🤽🏻‍♀️ "\U0001f93d\U0001f3fb\U0000200d\U00002640", # 🤽🏻‍♀ "\U0001f93d\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🤽🏾‍♀️ "\U0001f93d\U0001f3fe\U0000200d\U00002640", # 🤽🏾‍♀ "\U0001f93d\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🤽🏼‍♀️ "\U0001f93d\U0001f3fc\U0000200d\U00002640", # 🤽🏼‍♀ "\U0001f93d\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🤽🏽‍♀️ "\U0001f93d\U0001f3fd\U0000200d\U00002640", # 🤽🏽‍♀ "\U0001f46e\U0000200d\U00002640\U0000fe0f", # 👮‍♀️ "\U0001f46e\U0000200d\U00002640", # 👮‍♀ "\U0001f46e\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 👮🏿‍♀️ "\U0001f46e\U0001f3ff\U0000200d\U00002640", # 👮🏿‍♀ "\U0001f46e\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 👮🏻‍♀️ "\U0001f46e\U0001f3fb\U0000200d\U00002640", # 👮🏻‍♀ "\U0001f46e\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 👮🏾‍♀️ "\U0001f46e\U0001f3fe\U0000200d\U00002640", # 👮🏾‍♀ "\U0001f46e\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 👮🏼‍♀️ "\U0001f46e\U0001f3fc\U0000200d\U00002640", # 👮🏼‍♀ "\U0001f46e\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 👮🏽‍♀️ "\U0001f46e\U0001f3fd\U0000200d\U00002640", # 👮🏽‍♀ "\U0001f64e\U0000200d\U00002640\U0000fe0f", # 🙎‍♀️ "\U0001f64e\U0000200d\U00002640", # 🙎‍♀ "\U0001f64e\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🙎🏿‍♀️ "\U0001f64e\U0001f3ff\U0000200d\U00002640", # 🙎🏿‍♀ "\U0001f64e\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🙎🏻‍♀️ "\U0001f64e\U0001f3fb\U0000200d\U00002640", # 🙎🏻‍♀ "\U0001f64e\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🙎🏾‍♀️ "\U0001f64e\U0001f3fe\U0000200d\U00002640", # 🙎🏾‍♀ "\U0001f64e\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🙎🏼‍♀️ "\U0001f64e\U0001f3fc\U0000200d\U00002640", # 🙎🏼‍♀ "\U0001f64e\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🙎🏽‍♀️ "\U0001f64e\U0001f3fd\U0000200d\U00002640", # 🙎🏽‍♀ "\U0001f64b\U0000200d\U00002640\U0000fe0f", # 🙋‍♀️ "\U0001f64b\U0000200d\U00002640", # 🙋‍♀ "\U0001f64b\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🙋🏿‍♀️ "\U0001f64b\U0001f3ff\U0000200d\U00002640", # 🙋🏿‍♀ "\U0001f64b\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🙋🏻‍♀️ "\U0001f64b\U0001f3fb\U0000200d\U00002640", # 🙋🏻‍♀ "\U0001f64b\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🙋🏾‍♀️ "\U0001f64b\U0001f3fe\U0000200d\U00002640", # 🙋🏾‍♀ "\U0001f64b\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🙋🏼‍♀️ "\U0001f64b\U0001f3fc\U0000200d\U00002640", # 🙋🏼‍♀ "\U0001f64b\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🙋🏽‍♀️ "\U0001f64b\U0001f3fd\U0000200d\U00002640", # 🙋🏽‍♀ "\U0001f469\U0000200d\U0001f9b0", # 👩‍🦰 "\U0001f6a3\U0000200d\U00002640\U0000fe0f", # 🚣‍♀️ "\U0001f6a3\U0000200d\U00002640", # 🚣‍♀ "\U0001f6a3\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🚣🏿‍♀️ "\U0001f6a3\U0001f3ff\U0000200d\U00002640", # 🚣🏿‍♀ "\U0001f6a3\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🚣🏻‍♀️ "\U0001f6a3\U0001f3fb\U0000200d\U00002640", # 🚣🏻‍♀ "\U0001f6a3\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🚣🏾‍♀️ "\U0001f6a3\U0001f3fe\U0000200d\U00002640", # 🚣🏾‍♀ "\U0001f6a3\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🚣🏼‍♀️ "\U0001f6a3\U0001f3fc\U0000200d\U00002640", # 🚣🏼‍♀ "\U0001f6a3\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🚣🏽‍♀️ "\U0001f6a3\U0001f3fd\U0000200d\U00002640", # 🚣🏽‍♀ "\U0001f3c3\U0000200d\U00002640\U0000fe0f", # 🏃‍♀️ "\U0001f3c3\U0000200d\U00002640", # 🏃‍♀ "\U0001f3c3\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🏃🏿‍♀️ "\U0001f3c3\U0001f3ff\U0000200d\U00002640", # 🏃🏿‍♀ "\U0001f3c3\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🏃‍♀️‍➡️ "\U0001f3c3\U0000200d\U00002640\U0000200d\U000027a1\U0000fe0f", # 🏃‍♀‍➡️ "\U0001f3c3\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1", # 🏃‍♀️‍➡ "\U0001f3c3\U0000200d\U00002640\U0000200d\U000027a1", # 🏃‍♀‍➡ "\U0001f3c3\U0001f3ff\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🏃🏿‍♀️‍➡️ "\U0001f3c3\U0001f3ff\U0000200d\U00002640\U0000200d\U000027a1\U0000fe0f", # 🏃🏿‍♀‍➡️ "\U0001f3c3\U0001f3ff\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1", # 🏃🏿‍♀️‍➡ "\U0001f3c3\U0001f3ff\U0000200d\U00002640\U0000200d\U000027a1", # 🏃🏿‍♀‍➡ "\U0001f3c3\U0001f3fb\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🏃🏻‍♀️‍➡️ "\U0001f3c3\U0001f3fb\U0000200d\U00002640\U0000200d\U000027a1\U0000fe0f", # 🏃🏻‍♀‍➡️ "\U0001f3c3\U0001f3fb\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1", # 🏃🏻‍♀️‍➡ "\U0001f3c3\U0001f3fb\U0000200d\U00002640\U0000200d\U000027a1", # 🏃🏻‍♀‍➡ "\U0001f3c3\U0001f3fe\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🏃🏾‍♀️‍➡️ "\U0001f3c3\U0001f3fe\U0000200d\U00002640\U0000200d\U000027a1\U0000fe0f", # 🏃🏾‍♀‍➡️ "\U0001f3c3\U0001f3fe\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1", # 🏃🏾‍♀️‍➡ "\U0001f3c3\U0001f3fe\U0000200d\U00002640\U0000200d\U000027a1", # 🏃🏾‍♀‍➡ "\U0001f3c3\U0001f3fc\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🏃🏼‍♀️‍➡️ "\U0001f3c3\U0001f3fc\U0000200d\U00002640\U0000200d\U000027a1\U0000fe0f", # 🏃🏼‍♀‍➡️ "\U0001f3c3\U0001f3fc\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1", # 🏃🏼‍♀️‍➡ "\U0001f3c3\U0001f3fc\U0000200d\U00002640\U0000200d\U000027a1", # 🏃🏼‍♀‍➡ "\U0001f3c3\U0001f3fd\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🏃🏽‍♀️‍➡️ "\U0001f3c3\U0001f3fd\U0000200d\U00002640\U0000200d\U000027a1\U0000fe0f", # 🏃🏽‍♀‍➡️ "\U0001f3c3\U0001f3fd\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1", # 🏃🏽‍♀️‍➡ "\U0001f3c3\U0001f3fd\U0000200d\U00002640\U0000200d\U000027a1", # 🏃🏽‍♀‍➡ "\U0001f3c3\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🏃🏻‍♀️ "\U0001f3c3\U0001f3fb\U0000200d\U00002640", # 🏃🏻‍♀ "\U0001f3c3\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🏃🏾‍♀️ "\U0001f3c3\U0001f3fe\U0000200d\U00002640", # 🏃🏾‍♀ "\U0001f3c3\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🏃🏼‍♀️ "\U0001f3c3\U0001f3fc\U0000200d\U00002640", # 🏃🏼‍♀ "\U0001f3c3\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🏃🏽‍♀️ "\U0001f3c3\U0001f3fd\U0000200d\U00002640", # 🏃🏽‍♀ "\U0001f469\U0000200d\U0001f52c", # 👩‍🔬 "\U0001f469\U0001f3ff\U0000200d\U0001f52c", # 👩🏿‍🔬 "\U0001f469\U0001f3fb\U0000200d\U0001f52c", # 👩🏻‍🔬 "\U0001f469\U0001f3fe\U0000200d\U0001f52c", # 👩🏾‍🔬 "\U0001f469\U0001f3fc\U0000200d\U0001f52c", # 👩🏼‍🔬 "\U0001f469\U0001f3fd\U0000200d\U0001f52c", # 👩🏽‍🔬 "\U0001f937\U0000200d\U00002640\U0000fe0f", # 🤷‍♀️ "\U0001f937\U0000200d\U00002640", # 🤷‍♀ "\U0001f937\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🤷🏿‍♀️ "\U0001f937\U0001f3ff\U0000200d\U00002640", # 🤷🏿‍♀ "\U0001f937\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🤷🏻‍♀️ "\U0001f937\U0001f3fb\U0000200d\U00002640", # 🤷🏻‍♀ "\U0001f937\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🤷🏾‍♀️ "\U0001f937\U0001f3fe\U0000200d\U00002640", # 🤷🏾‍♀ "\U0001f937\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🤷🏼‍♀️ "\U0001f937\U0001f3fc\U0000200d\U00002640", # 🤷🏼‍♀ "\U0001f937\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🤷🏽‍♀️ "\U0001f937\U0001f3fd\U0000200d\U00002640", # 🤷🏽‍♀ "\U0001f469\U0000200d\U0001f3a4", # 👩‍🎤 "\U0001f469\U0001f3ff\U0000200d\U0001f3a4", # 👩🏿‍🎤 "\U0001f469\U0001f3fb\U0000200d\U0001f3a4", # 👩🏻‍🎤 "\U0001f469\U0001f3fe\U0000200d\U0001f3a4", # 👩🏾‍🎤 "\U0001f469\U0001f3fc\U0000200d\U0001f3a4", # 👩🏼‍🎤 "\U0001f469\U0001f3fd\U0000200d\U0001f3a4", # 👩🏽‍🎤 "\U0001f9cd\U0000200d\U00002640\U0000fe0f", # 🧍‍♀️ "\U0001f9cd\U0000200d\U00002640", # 🧍‍♀ "\U0001f9cd\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🧍🏿‍♀️ "\U0001f9cd\U0001f3ff\U0000200d\U00002640", # 🧍🏿‍♀ "\U0001f9cd\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🧍🏻‍♀️ "\U0001f9cd\U0001f3fb\U0000200d\U00002640", # 🧍🏻‍♀ "\U0001f9cd\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🧍🏾‍♀️ "\U0001f9cd\U0001f3fe\U0000200d\U00002640", # 🧍🏾‍♀ "\U0001f9cd\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🧍🏼‍♀️ "\U0001f9cd\U0001f3fc\U0000200d\U00002640", # 🧍🏼‍♀ "\U0001f9cd\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🧍🏽‍♀️ "\U0001f9cd\U0001f3fd\U0000200d\U00002640", # 🧍🏽‍♀ "\U0001f469\U0000200d\U0001f393", # 👩‍🎓 "\U0001f469\U0001f3ff\U0000200d\U0001f393", # 👩🏿‍🎓 "\U0001f469\U0001f3fb\U0000200d\U0001f393", # 👩🏻‍🎓 "\U0001f469\U0001f3fe\U0000200d\U0001f393", # 👩🏾‍🎓 "\U0001f469\U0001f3fc\U0000200d\U0001f393", # 👩🏼‍🎓 "\U0001f469\U0001f3fd\U0000200d\U0001f393", # 👩🏽‍🎓 "\U0001f9b8\U0000200d\U00002640\U0000fe0f", # 🦸‍♀️ "\U0001f9b8\U0000200d\U00002640", # 🦸‍♀ "\U0001f9b8\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🦸🏿‍♀️ "\U0001f9b8\U0001f3ff\U0000200d\U00002640", # 🦸🏿‍♀ "\U0001f9b8\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🦸🏻‍♀️ "\U0001f9b8\U0001f3fb\U0000200d\U00002640", # 🦸🏻‍♀ "\U0001f9b8\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🦸🏾‍♀️ "\U0001f9b8\U0001f3fe\U0000200d\U00002640", # 🦸🏾‍♀ "\U0001f9b8\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🦸🏼‍♀️ "\U0001f9b8\U0001f3fc\U0000200d\U00002640", # 🦸🏼‍♀ "\U0001f9b8\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🦸🏽‍♀️ "\U0001f9b8\U0001f3fd\U0000200d\U00002640", # 🦸🏽‍♀ "\U0001f9b9\U0000200d\U00002640\U0000fe0f", # 🦹‍♀️ "\U0001f9b9\U0000200d\U00002640", # 🦹‍♀ "\U0001f9b9\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🦹🏿‍♀️ "\U0001f9b9\U0001f3ff\U0000200d\U00002640", # 🦹🏿‍♀ "\U0001f9b9\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🦹🏻‍♀️ "\U0001f9b9\U0001f3fb\U0000200d\U00002640", # 🦹🏻‍♀ "\U0001f9b9\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🦹🏾‍♀️ "\U0001f9b9\U0001f3fe\U0000200d\U00002640", # 🦹🏾‍♀ "\U0001f9b9\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🦹🏼‍♀️ "\U0001f9b9\U0001f3fc\U0000200d\U00002640", # 🦹🏼‍♀ "\U0001f9b9\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🦹🏽‍♀️ "\U0001f9b9\U0001f3fd\U0000200d\U00002640", # 🦹🏽‍♀ "\U0001f3c4\U0000200d\U00002640\U0000fe0f", # 🏄‍♀️ "\U0001f3c4\U0000200d\U00002640", # 🏄‍♀ "\U0001f3c4\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🏄🏿‍♀️ "\U0001f3c4\U0001f3ff\U0000200d\U00002640", # 🏄🏿‍♀ "\U0001f3c4\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🏄🏻‍♀️ "\U0001f3c4\U0001f3fb\U0000200d\U00002640", # 🏄🏻‍♀ "\U0001f3c4\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🏄🏾‍♀️ "\U0001f3c4\U0001f3fe\U0000200d\U00002640", # 🏄🏾‍♀ "\U0001f3c4\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🏄🏼‍♀️ "\U0001f3c4\U0001f3fc\U0000200d\U00002640", # 🏄🏼‍♀ "\U0001f3c4\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🏄🏽‍♀️ "\U0001f3c4\U0001f3fd\U0000200d\U00002640", # 🏄🏽‍♀ "\U0001f3ca\U0000200d\U00002640\U0000fe0f", # 🏊‍♀️ "\U0001f3ca\U0000200d\U00002640", # 🏊‍♀ "\U0001f3ca\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🏊🏿‍♀️ "\U0001f3ca\U0001f3ff\U0000200d\U00002640", # 🏊🏿‍♀ "\U0001f3ca\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🏊🏻‍♀️ "\U0001f3ca\U0001f3fb\U0000200d\U00002640", # 🏊🏻‍♀ "\U0001f3ca\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🏊🏾‍♀️ "\U0001f3ca\U0001f3fe\U0000200d\U00002640", # 🏊🏾‍♀ "\U0001f3ca\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🏊🏼‍♀️ "\U0001f3ca\U0001f3fc\U0000200d\U00002640", # 🏊🏼‍♀ "\U0001f3ca\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🏊🏽‍♀️ "\U0001f3ca\U0001f3fd\U0000200d\U00002640", # 🏊🏽‍♀ "\U0001f469\U0000200d\U0001f3eb", # 👩‍🏫 "\U0001f469\U0001f3ff\U0000200d\U0001f3eb", # 👩🏿‍🏫 "\U0001f469\U0001f3fb\U0000200d\U0001f3eb", # 👩🏻‍🏫 "\U0001f469\U0001f3fe\U0000200d\U0001f3eb", # 👩🏾‍🏫 "\U0001f469\U0001f3fc\U0000200d\U0001f3eb", # 👩🏼‍🏫 "\U0001f469\U0001f3fd\U0000200d\U0001f3eb", # 👩🏽‍🏫 "\U0001f469\U0000200d\U0001f4bb", # 👩‍💻 "\U0001f469\U0001f3ff\U0000200d\U0001f4bb", # 👩🏿‍💻 "\U0001f469\U0001f3fb\U0000200d\U0001f4bb", # 👩🏻‍💻 "\U0001f469\U0001f3fe\U0000200d\U0001f4bb", # 👩🏾‍💻 "\U0001f469\U0001f3fc\U0000200d\U0001f4bb", # 👩🏼‍💻 "\U0001f469\U0001f3fd\U0000200d\U0001f4bb", # 👩🏽‍💻 "\U0001f481\U0000200d\U00002640\U0000fe0f", # 💁‍♀️ "\U0001f481\U0000200d\U00002640", # 💁‍♀ "\U0001f481\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 💁🏿‍♀️ "\U0001f481\U0001f3ff\U0000200d\U00002640", # 💁🏿‍♀ "\U0001f481\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 💁🏻‍♀️ "\U0001f481\U0001f3fb\U0000200d\U00002640", # 💁🏻‍♀ "\U0001f481\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 💁🏾‍♀️ "\U0001f481\U0001f3fe\U0000200d\U00002640", # 💁🏾‍♀ "\U0001f481\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 💁🏼‍♀️ "\U0001f481\U0001f3fc\U0000200d\U00002640", # 💁🏼‍♀ "\U0001f481\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 💁🏽‍♀️ "\U0001f481\U0001f3fd\U0000200d\U00002640", # 💁🏽‍♀ "\U0001f9db\U0000200d\U00002640\U0000fe0f", # 🧛‍♀️ "\U0001f9db\U0000200d\U00002640", # 🧛‍♀ "\U0001f9db\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🧛🏿‍♀️ "\U0001f9db\U0001f3ff\U0000200d\U00002640", # 🧛🏿‍♀ "\U0001f9db\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🧛🏻‍♀️ "\U0001f9db\U0001f3fb\U0000200d\U00002640", # 🧛🏻‍♀ "\U0001f9db\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🧛🏾‍♀️ "\U0001f9db\U0001f3fe\U0000200d\U00002640", # 🧛🏾‍♀ "\U0001f9db\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🧛🏼‍♀️ "\U0001f9db\U0001f3fc\U0000200d\U00002640", # 🧛🏼‍♀ "\U0001f9db\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🧛🏽‍♀️ "\U0001f9db\U0001f3fd\U0000200d\U00002640", # 🧛🏽‍♀ "\U0001f6b6\U0000200d\U00002640\U0000fe0f", # 🚶‍♀️ "\U0001f6b6\U0000200d\U00002640", # 🚶‍♀ "\U0001f6b6\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 🚶🏿‍♀️ "\U0001f6b6\U0001f3ff\U0000200d\U00002640", # 🚶🏿‍♀ "\U0001f6b6\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🚶‍♀️‍➡️ "\U0001f6b6\U0000200d\U00002640\U0000200d\U000027a1\U0000fe0f", # 🚶‍♀‍➡️ "\U0001f6b6\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1", # 🚶‍♀️‍➡ "\U0001f6b6\U0000200d\U00002640\U0000200d\U000027a1", # 🚶‍♀‍➡ "\U0001f6b6\U0001f3ff\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🚶🏿‍♀️‍➡️ "\U0001f6b6\U0001f3ff\U0000200d\U00002640\U0000200d\U000027a1\U0000fe0f", # 🚶🏿‍♀‍➡️ "\U0001f6b6\U0001f3ff\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1", # 🚶🏿‍♀️‍➡ "\U0001f6b6\U0001f3ff\U0000200d\U00002640\U0000200d\U000027a1", # 🚶🏿‍♀‍➡ "\U0001f6b6\U0001f3fb\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🚶🏻‍♀️‍➡️ "\U0001f6b6\U0001f3fb\U0000200d\U00002640\U0000200d\U000027a1\U0000fe0f", # 🚶🏻‍♀‍➡️ "\U0001f6b6\U0001f3fb\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1", # 🚶🏻‍♀️‍➡ "\U0001f6b6\U0001f3fb\U0000200d\U00002640\U0000200d\U000027a1", # 🚶🏻‍♀‍➡ "\U0001f6b6\U0001f3fe\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🚶🏾‍♀️‍➡️ "\U0001f6b6\U0001f3fe\U0000200d\U00002640\U0000200d\U000027a1\U0000fe0f", # 🚶🏾‍♀‍➡️ "\U0001f6b6\U0001f3fe\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1", # 🚶🏾‍♀️‍➡ "\U0001f6b6\U0001f3fe\U0000200d\U00002640\U0000200d\U000027a1", # 🚶🏾‍♀‍➡ "\U0001f6b6\U0001f3fc\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🚶🏼‍♀️‍➡️ "\U0001f6b6\U0001f3fc\U0000200d\U00002640\U0000200d\U000027a1\U0000fe0f", # 🚶🏼‍♀‍➡️ "\U0001f6b6\U0001f3fc\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1", # 🚶🏼‍♀️‍➡ "\U0001f6b6\U0001f3fc\U0000200d\U00002640\U0000200d\U000027a1", # 🚶🏼‍♀‍➡ "\U0001f6b6\U0001f3fd\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1\U0000fe0f", # 🚶🏽‍♀️‍➡️ "\U0001f6b6\U0001f3fd\U0000200d\U00002640\U0000200d\U000027a1\U0000fe0f", # 🚶🏽‍♀‍➡️ "\U0001f6b6\U0001f3fd\U0000200d\U00002640\U0000fe0f\U0000200d\U000027a1", # 🚶🏽‍♀️‍➡ "\U0001f6b6\U0001f3fd\U0000200d\U00002640\U0000200d\U000027a1", # 🚶🏽‍♀‍➡ "\U0001f6b6\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 🚶🏻‍♀️ "\U0001f6b6\U0001f3fb\U0000200d\U00002640", # 🚶🏻‍♀ "\U0001f6b6\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 🚶🏾‍♀️ "\U0001f6b6\U0001f3fe\U0000200d\U00002640", # 🚶🏾‍♀ "\U0001f6b6\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 🚶🏼‍♀️ "\U0001f6b6\U0001f3fc\U0000200d\U00002640", # 🚶🏼‍♀ "\U0001f6b6\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 🚶🏽‍♀️ "\U0001f6b6\U0001f3fd\U0000200d\U00002640", # 🚶🏽‍♀ "\U0001f473\U0000200d\U00002640\U0000fe0f", # 👳‍♀️ "\U0001f473\U0000200d\U00002640", # 👳‍♀ "\U0001f473\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 👳🏿‍♀️ "\U0001f473\U0001f3ff\U0000200d\U00002640", # 👳🏿‍♀ "\U0001f473\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 👳🏻‍♀️ "\U0001f473\U0001f3fb\U0000200d\U00002640", # 👳🏻‍♀ "\U0001f473\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 👳🏾‍♀️ "\U0001f473\U0001f3fe\U0000200d\U00002640", # 👳🏾‍♀ "\U0001f473\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 👳🏼‍♀️ "\U0001f473\U0001f3fc\U0000200d\U00002640", # 👳🏼‍♀ "\U0001f473\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 👳🏽‍♀️ "\U0001f473\U0001f3fd\U0000200d\U00002640", # 👳🏽‍♀ "\U0001f469\U0000200d\U0001f9b3", # 👩‍🦳 "\U0001f9d5", # 🧕 "\U0001f9d5\U0001f3ff", # 🧕🏿 "\U0001f9d5\U0001f3fb", # 🧕🏻 "\U0001f9d5\U0001f3fe", # 🧕🏾 "\U0001f9d5\U0001f3fc", # 🧕🏼 "\U0001f9d5\U0001f3fd", # 🧕🏽 "\U0001f470\U0000200d\U00002640\U0000fe0f", # 👰‍♀️ "\U0001f470\U0000200d\U00002640", # 👰‍♀ "\U0001f470\U0001f3ff\U0000200d\U00002640\U0000fe0f", # 👰🏿‍♀️ "\U0001f470\U0001f3ff\U0000200d\U00002640", # 👰🏿‍♀ "\U0001f470\U0001f3fb\U0000200d\U00002640\U0000fe0f", # 👰🏻‍♀️ "\U0001f470\U0001f3fb\U0000200d\U00002640", # 👰🏻‍♀ "\U0001f470\U0001f3fe\U0000200d\U00002640\U0000fe0f", # 👰🏾‍♀️ "\U0001f470\U0001f3fe\U0000200d\U00002640", # 👰🏾‍♀ "\U0001f470\U0001f3fc\U0000200d\U00002640\U0000fe0f", # 👰🏼‍♀️ "\U0001f470\U0001f3fc\U0000200d\U00002640", # 👰🏼‍♀ "\U0001f470\U0001f3fd\U0000200d\U00002640\U0000fe0f", # 👰🏽‍♀️ "\U0001f470\U0001f3fd\U0000200d\U00002640", # 👰🏽‍♀ "\U0001f469\U0000200d\U0001f9af", # 👩‍🦯 "\U0001f469\U0001f3ff\U0000200d\U0001f9af", # 👩🏿‍🦯 "\U0001f469\U0000200d\U0001f9af\U0000200d\U000027a1\U0000fe0f", # 👩‍🦯‍➡️ "\U0001f469\U0000200d\U0001f9af\U0000200d\U000027a1", # 👩‍🦯‍➡ "\U0001f469\U0001f3ff\U0000200d\U0001f9af\U0000200d\U000027a1\U0000fe0f", # 👩🏿‍🦯‍➡️ "\U0001f469\U0001f3ff\U0000200d\U0001f9af\U0000200d\U000027a1", # 👩🏿‍🦯‍➡ "\U0001f469\U0001f3fb\U0000200d\U0001f9af\U0000200d\U000027a1\U0000fe0f", # 👩🏻‍🦯‍➡️ "\U0001f469\U0001f3fb\U0000200d\U0001f9af\U0000200d\U000027a1", # 👩🏻‍🦯‍➡ "\U0001f469\U0001f3fe\U0000200d\U0001f9af\U0000200d\U000027a1\U0000fe0f", # 👩🏾‍🦯‍➡️ "\U0001f469\U0001f3fe\U0000200d\U0001f9af\U0000200d\U000027a1", # 👩🏾‍🦯‍➡ "\U0001f469\U0001f3fc\U0000200d\U0001f9af\U0000200d\U000027a1\U0000fe0f", # 👩🏼‍🦯‍➡️ "\U0001f469\U0001f3fc\U0000200d\U0001f9af\U0000200d\U000027a1", # 👩🏼‍🦯‍➡ "\U0001f469\U0001f3fd\U0000200d\U0001f9af\U0000200d\U000027a1\U0000fe0f", # 👩🏽‍🦯‍➡️ "\U0001f469\U0001f3fd\U0000200d\U0001f9af\U0000200d\U000027a1", # 👩🏽‍🦯‍➡ "\U0001f469\U0001f3fb\U0000200d\U0001f9af", # 👩🏻‍🦯 "\U0001f469\U0001f3fe\U0000200d\U0001f9af", # 👩🏾‍🦯 "\U0001f469\U0001f3fc\U0000200d\U0001f9af", # 👩🏼‍🦯 "\U0001f469\U0001f3fd\U0000200d\U0001f9af", # 👩🏽‍🦯 "\U0001f9df\U0000200d\U00002640\U0000fe0f", # 🧟‍♀️ "\U0001f9df\U0000200d\U00002640", # 🧟‍♀ "\U0001f462", # 👢 "\U0001f45a", # 👚 "\U0001f452", # 👒 "\U0001f461", # 👡 "\U0001f46d", # 👭 "\U0001f46d\U0001f3ff", # 👭🏿 "\U0001f469\U0001f3ff\U0000200d\U0001f91d\U0000200d\U0001f469\U0001f3fb", # 👩🏿‍🤝‍👩🏻 "\U0001f469\U0001f3ff\U0000200d\U0001f91d\U0000200d\U0001f469\U0001f3fe", # 👩🏿‍🤝‍👩🏾 "\U0001f469\U0001f3ff\U0000200d\U0001f91d\U0000200d\U0001f469\U0001f3fc", # 👩🏿‍🤝‍👩🏼 "\U0001f469\U0001f3ff\U0000200d\U0001f91d\U0000200d\U0001f469\U0001f3fd", # 👩🏿‍🤝‍👩🏽 "\U0001f46d\U0001f3fb", # 👭🏻 "\U0001f469\U0001f3fb\U0000200d\U0001f91d\U0000200d\U0001f469\U0001f3ff", # 👩🏻‍🤝‍👩🏿 "\U0001f469\U0001f3fb\U0000200d\U0001f91d\U0000200d\U0001f469\U0001f3fe", # 👩🏻‍🤝‍👩🏾 "\U0001f469\U0001f3fb\U0000200d\U0001f91d\U0000200d\U0001f469\U0001f3fc", # 👩🏻‍🤝‍👩🏼 "\U0001f469\U0001f3fb\U0000200d\U0001f91d\U0000200d\U0001f469\U0001f3fd", # 👩🏻‍🤝‍👩🏽 "\U0001f46d\U0001f3fe", # 👭🏾 "\U0001f469\U0001f3fe\U0000200d\U0001f91d\U0000200d\U0001f469\U0001f3ff", # 👩🏾‍🤝‍👩🏿 "\U0001f469\U0001f3fe\U0000200d\U0001f91d\U0000200d\U0001f469\U0001f3fb", # 👩🏾‍🤝‍👩🏻 "\U0001f469\U0001f3fe\U0000200d\U0001f91d\U0000200d\U0001f469\U0001f3fc", # 👩🏾‍🤝‍👩🏼 "\U0001f469\U0001f3fe\U0000200d\U0001f91d\U0000200d\U0001f469\U0001f3fd", # 👩🏾‍🤝‍👩🏽 "\U0001f46d\U0001f3fc", # 👭🏼 "\U0001f469\U0001f3fc\U0000200d\U0001f91d\U0000200d\U0001f469\U0001f3ff", # 👩🏼‍🤝‍👩🏿 "\U0001f469\U0001f3fc\U0000200d\U0001f91d\U0000200d\U0001f469\U0001f3fb", # 👩🏼‍🤝‍👩🏻 "\U0001f469\U0001f3fc\U0000200d\U0001f91d\U0000200d\U0001f469\U0001f3fe", # 👩🏼‍🤝‍👩🏾 "\U0001f469\U0001f3fc\U0000200d\U0001f91d\U0000200d\U0001f469\U0001f3fd", # 👩🏼‍🤝‍👩🏽 "\U0001f46d\U0001f3fd", # 👭🏽 "\U0001f469\U0001f3fd\U0000200d\U0001f91d\U0000200d\U0001f469\U0001f3ff", # 👩🏽‍🤝‍👩🏿 "\U0001f469\U0001f3fd\U0000200d\U0001f91d\U0000200d\U0001f469\U0001f3fb", # 👩🏽‍🤝‍👩🏻 "\U0001f469\U0001f3fd\U0000200d\U0001f91d\U0000200d\U0001f469\U0001f3fe", # 👩🏽‍🤝‍👩🏾 "\U0001f469\U0001f3fd\U0000200d\U0001f91d\U0000200d\U0001f469\U0001f3fc", # 👩🏽‍🤝‍👩🏼 "\U0001f46f\U0000200d\U00002640\U0000fe0f", # 👯‍♀️ "\U0001f46f\U0000200d\U00002640", # 👯‍♀ "\U0001f93c\U0000200d\U00002640\U0000fe0f", # 🤼‍♀️ "\U0001f93c\U0000200d\U00002640", # 🤼‍♀ "\U0001f6ba", # 🚺 "\U0001fab5", # 🪵 "\U0001f974", # 🥴 "\U0001f5fa\U0000fe0f", # 🗺️ "\U0001f5fa", # 🗺 "\U0001fab1", # 🪱 "\U0001f61f", # 😟 "\U0001f381", # 🎁 "\U0001f527", # 🔧 "\U0000270d\U0000fe0f", # ✍️ "\U0000270d", # ✍ "\U0000270d\U0001f3ff", # ✍🏿 "\U0000270d\U0001f3fb", # ✍🏻 "\U0000270d\U0001f3fe", # ✍🏾 "\U0000270d\U0001f3fc", # ✍🏼 "\U0000270d\U0001f3fd", # ✍🏽 "\U0001fa7b", # 🩻 "\U0001f9f6", # 🧶 "\U0001f971", # 🥱 "\U0001f7e1", # 🟡 "\U0001f49b", # 💛 "\U0001f7e8", # 🟨 "\U0001f4b4", # 💴 "\U0000262f\U0000fe0f", # ☯️ "\U0000262f", # ☯ "\U0001fa80", # 🪀 "\U0001f92a", # 🤪 "\U0001f993", # 🦓 "\U0001f910", # 🤐 "\U0001f9df", # 🧟 "\U0001f1e6\U0001f1fd", # 🇦🇽 ] allenporter-ical-fe8800b/ical/parsing/property.py000066400000000000000000000243301510550726100222020ustar00rootroot00000000000000"""Library for handling rfc5545 properties and parameters. A property is the definition of an individual attribute describing a calendar object or a calendar component. A property is also really just a "contentline", however properties in this file are the output of the parser and are provided in the context of where they live on a component hierarchy (e.g. attached to a component, or sub component). This is a very simple parser that converts lines in an iCalendar file into a an object structure with necessary relationships to interpret the meaning of the contentlines and how the parts break down into properties and parameters. This library does not attempt to interpret the meaning of the properties or types themselves. For example, given a content line of: DUE;VALUE=DATE:20070501 This library would create a ParseResults object with this structure: ParsedProperty( name='due', value='20070501', params=[ ParsedPropertyParameter( name='VALUE', values=['DATE'] ) ] } Note: This specific example may be a bit confusing because one of the property parameters is named "VALUE" which refers to the value type. """ # mypy: allow-any-generics from __future__ import annotations import re import datetime from dataclasses import dataclass from collections.abc import Iterator, Generator, Iterable from typing import Optional, Union, Sequence, Iterable from ical.exceptions import CalendarParseError # Characters that should be encoded in quotes _UNSAFE_CHAR_RE = re.compile(r"[,:;]") _RE_CONTROL_CHARS = re.compile("[\x00-\x08\x0a-\x1f\x7f]") _RE_NAME = re.compile("[A-Z0-9-]+") _NAME_DELIMITERS = (";", ":") _PARAM_DELIMITERS = (",", ";", ":") _QUOTE = '"' def _find_first( line: str, chars: Sequence[str], start: int | None = None ) -> int | None: """Find the earliest occurrence of any of the given characters in the line.""" if not chars: raise ValueError("At least one character must be provided to search for.") earliest: int | None = None for char in chars: pos = line.find(char, start) if pos != -1 and (earliest is None or pos < earliest): earliest = pos return earliest @dataclass class ParsedPropertyParameter: """An rfc5545 property parameter.""" name: str values: Sequence[Union[str, datetime.tzinfo]] """Values are typically strings, with a hack for TZID. The values may be overridden in the parse tree so that we can directly set the timezone information when parsing a date-time rather than combining with the calendar at runtime. That is, we update the tree with timezone information replacing a string TZID with the zoneinfo. """ @dataclass class ParsedProperty: """An rfc5545 property.""" name: str value: str params: Optional[list[ParsedPropertyParameter]] = None def get_parameter(self, name: str) -> ParsedPropertyParameter | None: """Return a single ParsedPropertyParameter with the specified name.""" if not self.params: return None for param in self.params: if param.name.lower() != name.lower(): continue return param return None def get_parameter_value(self, name: str) -> str | None: """Return the property parameter value.""" if not (param := self.get_parameter(name)): return None if len(param.values) > 1: raise ValueError( f"Expected only a single parameter string value, got {param.values}" ) return param.values[0] if isinstance(param.values[0], str) else None def ics(self) -> str: """Encode a ParsedProperty into the serialized format.""" result = [self.name.upper()] if self.params: result.append(";") result_params = [] for parameter in self.params: result_param_values = [] for value in parameter.values: if not isinstance(value, str): continue # Shouldn't happen; only strings are set by parsing # Property parameters with values contain a colon, semicolon, # or a comma character must be placed in quoted text if _UNSAFE_CHAR_RE.search(value): result_param_values.append(f'"{value}"') else: result_param_values.append(value) values = ",".join(result_param_values) result_params.append(f"{parameter.name.upper()}={values}") result.append(";".join(result_params)) result.append(":") result.append(str(self.value)) return "".join(result) @classmethod def from_ics(cls, contentline: str) -> "ParsedProperty": """Decode a ParsedProperty from an rfc5545 iCalendar content line. Will raise a CalendarParseError on failure. """ return _parse_line(contentline) def _parse_line(line: str) -> ParsedProperty: """Parse a single property line.""" # parse NAME if (name_end_pos := _find_first(line, _NAME_DELIMITERS)) is None: raise CalendarParseError( f"Invalid property line, expected {_NAME_DELIMITERS} after property name", detailed_error=line, ) property_name = line[0:name_end_pos] has_params = line[name_end_pos] == ";" pos = name_end_pos + 1 line_len = len(line) # parse PARAMS if any params: list[ParsedPropertyParameter] = [] if has_params: while pos < line_len: if (param_name_end_pos := line.find("=", pos)) == -1: raise CalendarParseError( f"Invalid parameter format: missing '=' after parameter name part '{line[pos:]}'", detailed_error=line, ) param_name = line[pos:param_name_end_pos] pos = param_name_end_pos + 1 # parse one or more comma-separated PARAM-VALUES param_values: list[str] = [] delimiter: str | None = None while delimiter is None or delimiter == ",": if pos >= line_len: raise CalendarParseError( "Unexpected end of line. Expected parameter value or delimiter.", detailed_error=line, ) param_value: str if line[pos] == _QUOTE: if (end_quote_pos := line.find(_QUOTE, pos + 1)) == -1: raise CalendarParseError( "Unexpected end of line: unclosed quoted parameter value.", detailed_error=line, ) param_value = line[pos + 1 : end_quote_pos] pos = end_quote_pos + 1 else: if (end_pos := _find_first(line, _PARAM_DELIMITERS, pos)) is None: raise CalendarParseError( "Unexpected end of line: missing parameter value delimiter.", detailed_error=line, ) param_value = line[pos:end_pos] pos = end_pos param_values.append(param_value) # After extracting value, pos is at the delimiter or EOL. if pos >= line_len: # E.g., quoted value ended right at EOL, or unquoted value consumed up to EOL. # A delimiter is always expected after a value within parameters. raise CalendarParseError( f"Unexpected end of line after parameter value '{param_value}'. Expected delimiter {_PARAM_DELIMITERS}.", detailed_error=line, ) if (delimiter := line[pos]) not in _PARAM_DELIMITERS: raise CalendarParseError( f"Expected {_PARAM_DELIMITERS} after parameter value, got '{delimiter}'", detailed_error=line, ) pos += 1 params.append(ParsedPropertyParameter(name=param_name, values=param_values)) if delimiter == ":": break # We are done with all parameters. property_name = property_name.upper() if not _RE_NAME.fullmatch(property_name): raise CalendarParseError( f"Invalid property name '{property_name}'", detailed_error=line ) for param in params: if not _RE_NAME.fullmatch(param.name): raise CalendarParseError( f"Invalid parameter name '{param.name}'", detailed_error=line ) for value in param.values: if not isinstance(value, str): raise ValueError( f"Invalid parameter value type: {type(value).__name__}" ) if value.find(_QUOTE) != -1: raise CalendarParseError( f"Parameter value '{value}' for parameter '{param.name}' is improperly quoted", detailed_error=line, ) if _RE_CONTROL_CHARS.search(value): raise CalendarParseError( f"Invalid parameter value '{value}' for parameter '{param.name}'", detailed_error=line, ) property_value = line[pos:] if _RE_CONTROL_CHARS.search(property_value): raise CalendarParseError( f"Property value contains control characters: {property_value}", detailed_error=line, ) return ParsedProperty( name=property_name.lower(), value=property_value, params=params if params else None, ) def parse_contentlines( contentlines: Iterable[str], ) -> Generator[ParsedProperty, None, None]: """Parse a contentlines into ParsedProperty objects.""" for contentline in contentlines: if not contentline: continue try: yield ParsedProperty.from_ics(contentline) except CalendarParseError as err: raise CalendarParseError( f"Calendar contents are not valid ICS format, see the detailed_error for more information", detailed_error=str(err), ) from err allenporter-ical-fe8800b/ical/py.typed000066400000000000000000000000001510550726100177640ustar00rootroot00000000000000allenporter-ical-fe8800b/ical/recur_adapter.py000066400000000000000000000072201510550726100214720ustar00rootroot00000000000000"""Component specific iterable functions. This module contains functions that are helpful for iterating over components in a calendar. This includes expanding recurring components or other functions for managing components from a list (e.g. grouping by uid). """ import datetime from collections.abc import Iterable from typing import Generic, TypeVar, cast from .iter import ( MergedIterable, RecurIterable, SortableItemValue, SpanOrderedItem, LazySortableItem, SortableItem, ) from .types.recur import RecurrenceId from .event import Event from .todo import Todo from .journal import Journal from .freebusy import FreeBusy from .timespan import Timespan ItemType = TypeVar("ItemType", bound="Event | Todo | Journal") _DateOrDatetime = datetime.datetime | datetime.date class RecurAdapter(Generic[ItemType]): """An adapter that expands an Event instance for a recurrence rule. This adapter is given an event, then invoked with a specific date/time instance that the event occurs on due to a recurrence rule. The event is copied with necessary updated fields to act as a flattened instance of the event. """ def __init__( self, item: ItemType, tzinfo: datetime.tzinfo | None = None, ) -> None: """Initialize the RecurAdapter.""" self._item = item self._duration = item.computed_duration self._tzinfo = tzinfo def get( self, dtstart: datetime.datetime | datetime.date ) -> SortableItem[Timespan, ItemType]: """Return a lazy sortable item.""" recur_id_dt = dtstart dtend = dtstart + self._duration if self._duration else dtstart # Make recurrence_id floating time to avoid dealing with serializing # TZID. This value will still be unique within the series and is in # the context of dtstart which may have a timezone. if isinstance(recur_id_dt, datetime.datetime) and recur_id_dt.tzinfo: recur_id_dt = recur_id_dt.replace(tzinfo=None) recurrence_id = RecurrenceId.__parse_property_value__(recur_id_dt) def build() -> ItemType: updates = { "dtstart": dtstart, "recurrence_id": recurrence_id, } if isinstance(self._item, Event) and self._item.dtend and dtend: updates["dtend"] = dtend if isinstance(self._item, Todo) and self._item.due and dtend: updates["due"] = dtend return cast(ItemType, self._item.model_copy(update=updates)) ts = Timespan.of(dtstart, dtend, self._tzinfo) return LazySortableItem(ts, build) def items_by_uid(items: list[ItemType]) -> dict[str, list[ItemType]]: items_by_uid: dict[str, list[ItemType]] = {} for item in items: if item.uid is None: raise ValueError("Todo must have a UID") if (values := items_by_uid.get(item.uid)) is None: values = [] items_by_uid[item.uid] = values values.append(item) return items_by_uid def merge_and_expand_items( items: list[ItemType], tzinfo: datetime.tzinfo ) -> Iterable[SpanOrderedItem[ItemType]]: """Merge and expand items that are recurring.""" iters: list[Iterable[SpanOrderedItem[ItemType]]] = [] for item in items: if not (recur := item.as_rrule()): iters.append( [ SortableItemValue( item.timespan_of(tzinfo), item, ) ] ) continue iters.append(RecurIterable(RecurAdapter(item, tzinfo=tzinfo).get, recur)) return MergedIterable(iters) allenporter-ical-fe8800b/ical/recurrence.py000066400000000000000000000070761510550726100210200ustar00rootroot00000000000000"""A grouping of component properties for describing recurrence rules. This is a standalone component used for defining recurrence rules for any other component. This is a faster performance library than invoking the full rfc5545 parser for the same data. As a result, it does not support the full rfc5545 specification, but only a subset of it. """ from __future__ import annotations import datetime import logging from collections.abc import Iterable from typing import Annotated, Union, Self from dateutil import rrule from pydantic import BeforeValidator, ConfigDict, Field, field_serializer from .parsing.property import ( parse_contentlines, ) from .parsing.component import ParsedComponent from .types.data_types import serialize_field from .types.date import DateEncoder from .types.recur import Recur from .types.date_time import DateTimeEncoder from .component import ComponentModel from .exceptions import CalendarParseError from .iter import RulesetIterable from .util import parse_date_and_datetime, parse_date_and_datetime_list _LOGGER = logging.getLogger(__name__) class Recurrences(ComponentModel): """A common set of recurrence related properties for calendar components.""" dtstart: Annotated[ Union[datetime.date, datetime.datetime, None], BeforeValidator(parse_date_and_datetime), ] = None """The start date for the event.""" rrule: list[Recur] = Field(default_factory=list) """The recurrence rule for the event.""" rdate: Annotated[ list[Union[datetime.date, datetime.datetime]], BeforeValidator(parse_date_and_datetime_list), ] = Field(default_factory=list) """Dates for the event.""" exdate: Annotated[ list[Union[datetime.date, datetime.datetime]], BeforeValidator(parse_date_and_datetime_list), ] = Field(default_factory=list) """Excluded dates for the event.""" @classmethod def from_basic_contentlines(cls, contentlines: list[str]) -> Self: """Parse a raw component from a list of contentlines making up the body. This is meant to be a performance optimized version of the parsing that is done in the main parser. It is used for parsing recurrences from a calendar component only. """ try: properties = list(parse_contentlines(contentlines)) except ValueError as err: raise CalendarParseError( "Failed to parse recurrence", detailed_error=str(err) ) from err component = ParsedComponent( name="recurrences", # Not used in the model properties=properties, ) return cls.model_validate(component.as_dict()) def as_rrule( self, dtstart: datetime.date | datetime.datetime | None = None ) -> Iterable[datetime.date | datetime.datetime]: """Return the set of recurrences as a rrule that emits start times.""" if dtstart is None: dtstart = self.dtstart if dtstart is None: raise ValueError("dtstart is required to generate recurrences") return RulesetIterable( dtstart, [rule.as_rrule(dtstart) for rule in self.rrule], self.rdate, self.exdate, ) def ics(self) -> list[str]: """Serialize the recurrence rules as strings.""" return [prop.ics() for prop in self.__encode_component_root__().properties] model_config = ConfigDict( validate_assignment=True, populate_by_name=True, ) serialize_fields = field_serializer("*")(serialize_field) # type: ignore[pydantic-field] allenporter-ical-fe8800b/ical/store.py000066400000000000000000000501131510550726100200050ustar00rootroot00000000000000"""Library for managing the lifecycle of components in a calendar. A store is like a manager for events within a Calendar, updating the necessary properties such as modification times, sequence numbers, and ids. This higher level API is a more convenient API than working with the lower level objects directly. """ # pylint: disable=unnecessary-lambda from __future__ import annotations import datetime import logging from collections.abc import Callable, Iterable, Generator from typing import Any, TypeVar, Generic, cast from .calendar import Calendar from .component import validate_recurrence_dates from .event import Event from .exceptions import StoreError, TodoStoreError, EventStoreError from .iter import RulesetIterable from .list import todo_list_view from .timezone import Timezone from .todo import Todo, TodoStatus from .types import Range, Recur, RecurrenceId, RelationshipType from .tzif.timezoneinfo import TimezoneInfoError from .util import dtstamp_factory, local_timezone _LOGGER = logging.getLogger(__name__) __all__ = [ "EventStore", "EventStoreError", "TodoStore", "TodoStoreError", "StoreError", ] _T = TypeVar("_T", bound="Event | Todo") # We won't be able to edit dates more than 100 years in the future, but this # should be sufficient for most use cases. _MAX_SCAN_DATE = datetime.date.today() + datetime.timedelta(days=365 * 100) def _ensure_timezone( dtvalue: datetime.datetime | datetime.date | None, timezones: list[Timezone] ) -> Timezone | None: """Create a timezone object for the specified date if it does not already exist.""" if ( not isinstance(dtvalue, datetime.datetime) or not dtvalue.utcoffset() or not dtvalue.tzinfo ): return None # Verify this timezone does not already exist. The number of timezones # in a calendar is typically very small so iterate over the whole thing # to avoid any synchronization/cache issues. key = str(dtvalue.tzinfo) for timezone in timezones: if timezone.tz_id == key: return None try: return Timezone.from_tzif(key) except TimezoneInfoError as err: raise EventStoreError( f"No timezone information available for event: {key}" ) from err def _match_item(item: _T, uid: str, recurrence_id: str | None) -> bool: """Return True if the item is an instance of a recurring event.""" if item.uid != uid: return False if recurrence_id is None: # Match all items with the specified uids return True # Match a single item with the specified recurrence_id. If the item is an # edited instance match return it if item.recurrence_id == recurrence_id: _LOGGER.debug("Matched exact recurrence_id: %s", item) return True # Otherwise, determine if this instance is in the series _LOGGER.debug( "Expanding item %s %s to look for match of %s", uid, item.dtstart, recurrence_id ) dtstart = RecurrenceId.to_value(recurrence_id) if isinstance(dtstart, datetime.datetime) and isinstance( item.dtstart, datetime.datetime ): # The recurrence_id does not support timezone information, so put it in the # same timezone as the item to compare. if item.dtstart.tzinfo is not None: dtstart = dtstart.replace(tzinfo=item.dtstart.tzinfo) for dt in item.as_rrule() or (): if isinstance(dt, datetime.datetime): if dt.date() > _MAX_SCAN_DATE: _LOGGER.debug("Aborting scan, date %s is beyond max scan date", dt) break elif dt > _MAX_SCAN_DATE: _LOGGER.debug("Aborting scan, date %s is beyond max scan date", dt) break if dt == dtstart: _LOGGER.debug("Found expanded recurrence_id: %s", dt) return True return False def _match_items( items: list[_T], uid: str, recurrence_id: str | None ) -> Generator[tuple[int, _T], None, None]: """Return items from the list that match the uid and recurrence_id.""" for index, item in enumerate(items): if _match_item(item, uid, recurrence_id): yield index, item def _prepare_update( store_item: Event | Todo, item: Event | Todo, recurrence_id: str | None = None, recurrence_range: Range = Range.NONE, ) -> dict[str, Any]: """Prepare an update to an existing event.""" partial_update = item.model_dump( exclude_unset=True, exclude={"dtstamp", "uid", "sequence", "created", "last_modified"}, ) _LOGGER.debug("Preparing update update=%s", item) update = { "created": store_item.dtstamp, "sequence": (store_item.sequence + 1) if store_item.sequence else 1, "last_modified": item.dtstamp, **partial_update, "dtstamp": item.dtstamp, } if ( isinstance(item, Todo) and isinstance(store_item, Todo) and item.status and not item.completed ): if ( store_item.status != TodoStatus.COMPLETED and item.status == TodoStatus.COMPLETED ): update["completed"] = item.dtstamp if store_item.completed and item.status != TodoStatus.COMPLETED: update["completed"] = None if rrule := update.get("rrule"): update["rrule"] = Recur.model_validate(rrule) if recurrence_id and store_item.rrule: # Forking a new event off the old event preserves the original uid and # recurrence_id. update.update( { "uid": store_item.uid, "recurrence_id": recurrence_id, } ) if recurrence_range == Range.NONE: # The new event copied from the original is a single instance, # which is not recurring. update["rrule"] = None else: # Overwriting with a new recurring event update["created"] = item.dtstamp # Adjust start and end time of the event dtstart: datetime.datetime | datetime.date = RecurrenceId.to_value( recurrence_id ) if item.dtstart: dtstart = item.dtstart update["dtstart"] = dtstart # Event either has a duration (which should already be set) or has # an explicit end which needs to be realigned to new start time. if isinstance(store_item, Event) and store_item.dtend: update["dtend"] = dtstart + store_item.computed_duration return update class GenericStore(Generic[_T]): """A a store manages the lifecycle of items on a Calendar.""" def __init__( self, items: list[_T], timezones: list[Timezone], exc: type[StoreError], dtstamp_fn: Callable[[], datetime.datetime] = lambda: dtstamp_factory(), tzinfo: datetime.tzinfo | None = None, ): """Initialize the EventStore.""" self._items = items self._timezones = timezones self._exc = exc self._dtstamp_fn = dtstamp_fn self._tzinfo = tzinfo or local_timezone() def add(self, item: _T) -> _T: """Add the specified item to the calendar. This will handle assigning modification dates, sequence numbers, etc if those fields are unset. The store will ensure the `ical.calendar.Calendar` has the necessary `ical.timezone.Timezone` needed to fully specify the time information when encoded. """ update: dict[str, Any] = {} if not item.created: update["created"] = item.dtstamp if item.sequence is None: update["sequence"] = 0 if isinstance(item, Todo) and not item.dtstart: if item.due: update["dtstart"] = item.due - datetime.timedelta(days=1) else: update["dtstart"] = datetime.datetime.now(tz=self._tzinfo) if ( isinstance(item, Todo) and not item.completed and item.status == TodoStatus.COMPLETED ): update["completed"] = item.dtstamp new_item = cast(_T, item.copy_and_validate(update=update)) # The store can only manage cascading deletes for some relationship types for relation in new_item.related_to or (): if relation.reltype != RelationshipType.PARENT: raise self._exc(f"Unsupported relationship type {relation.reltype}") _LOGGER.debug("Adding item: %s", new_item) self._ensure_timezone(item.dtstart) if isinstance(item, Event) and item.dtend: self._ensure_timezone(item.dtend) self._items.append(new_item) return new_item def delete( self, uid: str, recurrence_id: str | None = None, recurrence_range: Range = Range.NONE, ) -> None: """Delete the item from the calendar. This method is used to delete an existing item. For a recurring item either the whole item or instances of an item may be deleted. To delete the complete range of a recurring item, the `uid` property for the item must be specified and the `recurrence_id` should not be specified. To delete an individual instance of the item the `recurrence_id` must be specified. When deleting individual instances, the range property may specify if deletion of just a specific instance, or a range of instances. """ items_to_delete: list[_T] = [ item for _, item in _match_items(self._items, uid, recurrence_id) ] if not items_to_delete: raise self._exc( f"No existing item with uid/recurrence_id: {uid}/{recurrence_id}" ) for store_item in items_to_delete: self._apply_delete(store_item, recurrence_id, recurrence_range) def _apply_delete( self, store_item: _T, recurrence_id: str | None = None, recurrence_range: Range = Range.NONE, ) -> None: if ( recurrence_id and recurrence_range == Range.THIS_AND_FUTURE and RecurrenceId.to_value(recurrence_id) == store_item.dtstart ): # Editing the first instance and all forward is the same as editing the # entire series so don't bother forking a new event recurrence_id = None children = [] for event in self._items: for relation in event.related_to or (): if ( relation.reltype == RelationshipType.PARENT and relation.uid == store_item.uid ): children.append(event) for child in children: self._items.remove(child) # Deleting all instances in the series if not recurrence_id or not store_item.rrule: self._items.remove(store_item) return exdate = RecurrenceId.to_value(recurrence_id) if recurrence_range == Range.NONE: # A single recurrence instance is removed. Add an exclusion to # to the event. # RecurrenceId does not support timezone information. The exclusion # must have the same timezone as the item to compare. if ( isinstance(exdate, datetime.datetime) and isinstance(store_item.dtstart, datetime.datetime) and store_item.dtstart.tzinfo ): exdate = exdate.replace(tzinfo=store_item.dtstart.tzinfo) store_item.exdate.append(exdate) return # Assumes any recurrence deletion is valid, and that overwriting # the "until" value will not produce more instances. UNTIL is # inclusive so it can't include the specified exdate. FREQ=DAILY # is the lowest frequency supported so subtracting one day is # safe and works for both dates and datetimes. store_item.rrule.count = None if ( isinstance(exdate, datetime.datetime) and isinstance(store_item.dtstart, datetime.datetime) and store_item.dtstart.tzinfo ): exdate = exdate.astimezone(datetime.timezone.utc) store_item.rrule.until = exdate - datetime.timedelta(days=1) now = self._dtstamp_fn() store_item.dtstamp = now store_item.last_modified = now def edit( self, uid: str, item: _T, recurrence_id: str | None = None, recurrence_range: Range = Range.NONE, ) -> None: """Update the item with the specified uid. The specified item should be created with minimal fields, just including the fields that should be updated. The default fields such as `uid` and `dtstamp` may be used to set the uid for a new created item when updating a recurring item, or for any modification times. For a recurring item, either the whole item or individual instances of the item may be edited. To edit the complete range of a recurring item the `uid` property must be specified and the `recurrence_id` should not be specified. To edit an individual instances of the item the `recurrence_id` must be specified. The `recurrence_range` determines if just that individual instance is updated or all items following as well. The store will ensure the `ical.calendar.Calendar` has the necessary `ical.timezone.Timezone` needed to fully specify the item time information when encoded. """ items_to_edit: list[tuple[int, _T]] = [ (index, item) for index, item in _match_items(self._items, uid, recurrence_id) ] if not items_to_edit: raise self._exc( f"No existing item with uid/recurrence_id: {uid}/{recurrence_id}" ) for store_index, store_item in items_to_edit: self._apply_edit( store_index, store_item, item, recurrence_id, recurrence_range ) def _apply_edit( self, store_index: int, store_item: _T, item: _T, recurrence_id: str | None = None, recurrence_range: Range = Range.NONE, ) -> None: if ( recurrence_id and recurrence_range == Range.THIS_AND_FUTURE and RecurrenceId.to_value(recurrence_id) == store_item.dtstart ): # Editing the first instance and all forward is the same as editing the # entire series so don't bother forking a new item recurrence_id = None update = _prepare_update(store_item, item, recurrence_id, recurrence_range) if recurrence_range == Range.NONE: # Changing the recurrence rule of a single item in the middle of the series # is not allowed. It is allowed to convert a single instance item to recurring. if item.rrule and store_item.rrule: if item.rrule.as_rrule_str() != store_item.rrule.as_rrule_str(): raise self._exc( f"Can't update single instance with rrule (rrule={item.rrule})" ) item.rrule = None # Make a deep copy since deletion may update this objects recurrence rules new_item = cast(_T, store_item.copy_and_validate(update=update)) if ( recurrence_id and new_item.rrule and new_item.rrule.count and store_item.dtstart ): # The recurring item count needs to skip any items that # come before the start of the new item. Use a RulesetIterable # to handle workarounds for dateutil.rrule limitations. dtstart: datetime.date | datetime.datetime = update["dtstart"] ruleset = RulesetIterable( store_item.dtstart, [new_item.rrule.as_rrule(store_item.dtstart)], [], [], ) for dtvalue in ruleset: if dtvalue >= dtstart: break new_item.rrule.count = new_item.rrule.count - 1 # The store can only manage cascading deletes for some relationship types for relation in new_item.related_to or (): if relation.reltype != RelationshipType.PARENT: raise self._exc(f"Unsupported relationship type {relation.reltype}") self._ensure_timezone(new_item.dtstart) if isinstance(new_item, Event) and new_item.dtend: self._ensure_timezone(new_item.dtend) # Editing a single instance of a recurring item is like deleting that instance # then adding a new instance on the specified date. If recurrence id is not # specified then the entire item is replaced. self.delete( store_item.uid, recurrence_id=recurrence_id, recurrence_range=recurrence_range, ) self._items.insert(store_index, new_item) def _ensure_timezone( self, dtvalue: datetime.datetime | datetime.date | None ) -> None: if (new_timezone := _ensure_timezone(dtvalue, self._timezones)) is not None: self._timezones.append(new_timezone) class EventStore(GenericStore[Event]): """An event store manages the lifecycle of events on a Calendar. An `ical.calendar.Calendar` is a lower level object that can be directly manipulated to add/remove an `ical.event.Event`. That is, it does not handle updating timestamps, incrementing sequence numbers, or managing lifecycle of a recurring event during an update. Here is an example for setting up an `EventStore`: ```python import datetime from ical.calendar import Calendar from ical.event import Event from ical.store import EventStore from ical.types import Recur calendar = Calendar() store = EventStore(calendar) event = Event( summary="Event summary", start="2022-07-03", end="2022-07-04", rrule=Recur.from_rrule("FREQ=WEEKLY;COUNT=3"), ) store.add(event) ``` This will add events to the calendar: ```python3 for event in calendar.timeline: print(event.summary, event.uid, event.recurrence_id, event.dtstart) ``` With output like this: ``` Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220703 2022-07-03 Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220710 2022-07-10 Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220717 2022-07-17 ``` You may also delete an event, or a specific instance of a recurring event: ```python # Delete a single instance of the recurring event store.delete(uid=event.uid, recurrence_id="20220710") ``` Then viewing the store using the `print` example removes the individual instance in the event: ``` Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220703 2022-07-03 Event summary a521cf45-2c02-11ed-9e5c-066a07ffbaf5 20220717 2022-07-17 ``` Editing an event is also supported: ```python store.edit("event-uid-1", Event(summary="New Summary")) ``` """ def __init__( self, calendar: Calendar, dtstamp_fn: Callable[[], datetime.datetime] = lambda: dtstamp_factory(), ): """Initialize the EventStore.""" super().__init__( calendar.events, calendar.timezones, EventStoreError, dtstamp_fn, tzinfo=None, ) class TodoStore(GenericStore[Todo]): """A To-do store manages the lifecycle of to-dos on a Calendar.""" def __init__( self, calendar: Calendar, tzinfo: datetime.tzinfo | None = None, dtstamp_fn: Callable[[], datetime.datetime] = lambda: dtstamp_factory(), ): """Initialize the TodoStore.""" super().__init__( calendar.todos, calendar.timezones, TodoStoreError, dtstamp_fn, tzinfo=tzinfo, ) self._calendar = calendar def todo_list(self, dtstart: datetime.datetime | None = None) -> Iterable[Todo]: """Return a list of all todos on the calendar. This view accounts for recurring todos. """ return todo_list_view(self._calendar.todos, dtstart) allenporter-ical-fe8800b/ical/timeline.py000066400000000000000000000024661510550726100204670ustar00rootroot00000000000000"""A Timeline is a set of events on a calendar. A timeline can be used to iterate over all events, including expanded recurring events. A timeline also supports methods to scan ranges of events like returning all events happening today or after a specific date. """ from __future__ import annotations import datetime from collections.abc import Iterable, Iterator from typing import TypeVar, Generic, Protocol from .event import Event from .iter import ( SortableItemTimeline, SpanOrderedItem, ) from .recur_adapter import merge_and_expand_items, ItemType __all__ = ["Timeline", "generic_timeline"] Timeline = SortableItemTimeline[Event] def calendar_timeline(events: list[Event], tzinfo: datetime.tzinfo) -> Timeline: """Create a timeline for events on a calendar, including recurrence.""" return Timeline(merge_and_expand_items(events, tzinfo)) def generic_timeline( items: list[ItemType], tzinfo: datetime.tzinfo ) -> SortableItemTimeline[ItemType]: """Return a timeline view of events on the calendar. All events are returned as if the attendee is viewing from the specified timezone. For example, this affects the order that All Day events are returned. """ return SortableItemTimeline( merge_and_expand_items( items, tzinfo, ) ) allenporter-ical-fe8800b/ical/timespan.py000066400000000000000000000077571510550726100205110ustar00rootroot00000000000000"""A timespan is defined by a start and end time and used for comparisons. A common way to compare events is by comparing their start and end time. Often there are corner cases such as an all day event which does not specify a specific time, but instead needs to be interpreted in the timezone of the attendee. A timespan is unambiguous in that it is created with that timezone. A `Timespan` is not instantiated directly, but created by a calendar component such as an `Event`. """ from __future__ import annotations import datetime from typing import Any from .util import normalize_datetime __all__ = ["Timespan"] class Timespan: """An unambiguous definition of a start and end time. A timespan is not ambiguous in that it can never be a "floating" time range and instead is always aligned to some kind of timezone or utc. """ def __init__(self, start: datetime.datetime, end: datetime.datetime) -> None: """...""" self._start = start self._end = end if not self._start.tzinfo: raise ValueError(f"Start time did not have a timezone: {self._start}") self._tzinfo = self._start.tzinfo @classmethod def of( # pylint: disable=invalid-name] cls, start: datetime.date | datetime.datetime, end: datetime.date | datetime.datetime, tzinfo: datetime.tzinfo | None = None, ) -> "Timespan": """Create a Timespan for the specified date range.""" return Timespan( normalize_datetime(start, tzinfo), normalize_datetime(end, tzinfo) ) @property def start(self) -> datetime.datetime: """Return the timespan start as a datetime.""" return self._start @property def end(self) -> datetime.datetime: """Return the timespan end as a datetime.""" return self._end @property def tzinfo(self) -> datetime.tzinfo: """Return the timespan timezone.""" return self._tzinfo @property def duration(self) -> datetime.timedelta: """Return the timespan duration.""" return self.end - self.start def starts_within(self, other: "Timespan") -> bool: """Return True if this timespan starts while the other timespan is active.""" return other.start <= self.start < other.end def ends_within(self, other: "Timespan") -> bool: """Return True if this timespan ends while the other event is active.""" return other.start <= self.end < other.end def intersects(self, other: "Timespan") -> bool: """Return True if this timespan overlaps with the other event.""" return ( other.start <= self.start < other.end or other.start < self.end <= other.end or self.start <= other.start < self.end or self.start < other.end <= self.end ) def includes(self, other: "Timespan") -> bool: """Return True if the other timespan starts and ends within this event.""" return ( self.start <= other.start < self.end and self.start <= other.end < self.end ) def is_included_in(self, other: "Timespan") -> bool: """Return True if this timespan starts and ends within the other event.""" return other.start <= self.start and self.end < other.end def __lt__(self, other: Any) -> bool: if not isinstance(other, Timespan): return NotImplemented return (self._start, self._end) < (other.start, other.end) def __gt__(self, other: Any) -> bool: if not isinstance(other, Timespan): return NotImplemented return (self._start, self._end) > (other.start, other.end) def __le__(self, other: Any) -> bool: if not isinstance(other, Timespan): return NotImplemented return (self._start, self._end) <= (other.start, other.end) def __ge__(self, other: Any) -> bool: if not isinstance(other, Timespan): return NotImplemented return (self._start, self._end) >= (other.start, other.end) allenporter-ical-fe8800b/ical/timezone.py000066400000000000000000000301371510550726100205070ustar00rootroot00000000000000"""A grouping of component properties that defines a time zone. An iCal timezone is a complete description of a timezone, separate from the built-in timezones used by python datetime objects. You can think of this like fully persisting all timezone information referenced in the calendar for dates to reference. Timezones are captured to unambiguously describe time information to aid in interoperability between different calendaring systems. """ # pylint: disable=unnecessary-lambda from __future__ import annotations import copy import traceback import datetime import enum import logging from dataclasses import dataclass from typing import Annotated, Any, Iterable, Optional, Self, Union from dateutil.rrule import rruleset from pydantic import ( BeforeValidator, Field, field_serializer, field_validator, model_validator, ) from ical.types.data_types import serialize_field from .component import ComponentModel from .iter import MergedIterable, RecurIterable from .parsing.property import ParsedProperty from .types import Recur, Uri, UtcOffset from .tzif import timezoneinfo, tz_rule from .util import parse_date_and_datetime_list __all__ = [ "Timezone", "Observance", ] _LOGGER = logging.getLogger(__name__) # Assume that all tzif timezone rules start at an arbitrary old date. This library # typically only works with "go forward" dates, so we don't need to be completely # accurate and use the historical database of times. _TZ_START = datetime.datetime(2010, 1, 1, 0, 0, 0) _ZERO = datetime.timedelta(0) class Observance(ComponentModel): """A sub-component with properties for a set of timezone observances.""" # Has an alias of 'start' dtstart: Optional[datetime.datetime] = Field(default=None) """The first onset datetime (local time) for the observance.""" tz_offset_to: UtcOffset = Field(alias="tzoffsetto") """Gives the UTC offset for the time zone when this observance is in use.""" tz_offset_from: UtcOffset = Field(alias="tzoffsetfrom") """The timezone offset used when the onset of this time zone observance begins. The tz_offset_from and dtstart define the effective onset for the time zone sub-component. """ rrule: Optional[Recur] = None """The recurrence rule for the onset of observances defined in this sub-component.""" rdate: Annotated[ list[Union[datetime.date, datetime.datetime]], BeforeValidator(parse_date_and_datetime_list), ] = Field(default_factory=list) """A rule to determine the onset of the observances defined in this sub-component.""" tz_name: list[str] = Field(alias="tzname", default_factory=list) """A name for the observance.""" comment: list[str] = Field(default_factory=list) """Descriptive explanatory text.""" extras: list[ParsedProperty] = Field(default_factory=list) def __init__(self, **data: Any) -> None: """Initialize Timezone.""" if "start" in data: data["dtstart"] = data.pop("start") super().__init__(**data) @property def start_datetime(self) -> datetime.datetime: """Return the start of the observance.""" assert self.dtstart is not None return self.dtstart def as_ruleset(self) -> rruleset: """Represent the occurrence as a rule of repeated dates or datetimes.""" ruleset = rruleset() if self.rrule: ruleset.rrule(self.rrule.as_rrule(self.start_datetime)) for rdate in self.rdate: ruleset.rdate(rdate) return ruleset @field_validator("dtstart") @classmethod def verify_dtstart_local_time(cls, value: datetime.datetime) -> datetime.datetime: """Validate that dtstart is specified in a local time.""" if value.utcoffset() is not None: raise ValueError(f"Start time must be in local time format: {value}") return value serialize_fields = field_serializer("*")(serialize_field) # type: ignore[pydantic-field] class _ObservanceType(str, enum.Enum): """Type of a timezone observance.""" STANDARD = "STANDARD" DAYLIGHT = "DAYLIGHT" @dataclass class _ObservanceInfo: """Object holding observance information.""" observance_type: _ObservanceType observance: Observance def get( self, value: datetime.datetime | datetime.date, ) -> tuple[datetime.datetime | datetime.date, "_ObservanceInfo"]: """Adapt for an iterator over observances.""" return (value, self) class Timezone(ComponentModel): """A single free/busy entry on a calendar. A Timezone must have at least one definition of a standard or daylight sub-component. """ tz_id: str = Field(alias="tzid") """An identifier for this Timezone, unique within a calendar.""" standard: list[Observance] = Field(default_factory=list) """Describes the base offset from UTC for the time zone.""" daylight: list[Observance] = Field(default_factory=list) """Describes adjustments made to account for changes in daylight hours.""" tz_url: Optional[Uri] = Field(alias="tzurl", default=None) """Url that points to a published timezone definition.""" last_modified: Optional[datetime.datetime] = Field( alias="last-modified", default=None ) """Specifies the date and time that this time zone was last updated.""" # Unknown or unsupported properties extras: list[ParsedProperty] = Field(default_factory=list) @classmethod def from_tzif(cls, key: str, start: datetime.datetime = _TZ_START) -> Timezone: """Create a new Timezone from a tzif data source.""" info = timezoneinfo.read(key) rule = info.rule if not rule: raise ValueError("Unsupported timezoneinfo had no rule") dst_offset = rule.std.offset if rule.dst and rule.dst.offset: dst_offset = rule.dst.offset std_timezone_info = Observance( tz_name=[rule.std.name], tz_offset_to=UtcOffset(offset=rule.std.offset), tz_offset_from=UtcOffset(dst_offset), dtstart=start, ) daylight = [] if ( rule.dst and rule.dst_start and isinstance(rule.dst_start, tz_rule.RuleDate) and rule.dst_end and isinstance(rule.dst_end, tz_rule.RuleDate) ): std_timezone_info.rrule = Recur.model_validate( Recur.__parse_property_value__(rule.dst_end.rrule_str) ) std_timezone_info.dtstart = rule.dst_end.rrule_dtstart(start) daylight.append( Observance( tz_name=[rule.dst.name], tz_offset_to=UtcOffset(offset=rule.dst.offset), tz_offset_from=UtcOffset(offset=rule.std.offset), rrule=Recur.model_validate( Recur.__parse_property_value__(rule.dst_start.rrule_str) ), dtstart=rule.dst_start.rrule_dtstart(start), ) ) return Timezone(tz_id=key, standard=[std_timezone_info], daylight=daylight) def _observances( self, ) -> Iterable[tuple[datetime.datetime | datetime.date, _ObservanceInfo]]: return MergedIterable(self._std_observances() + self._dst_observances()) def _std_observances( self, ) -> list[Iterable[tuple[datetime.datetime | datetime.date, _ObservanceInfo]]]: iters: list[ Iterable[tuple[datetime.datetime | datetime.date, _ObservanceInfo]] ] = [] for observance in self.standard: iters.append( RecurIterable( _ObservanceInfo(_ObservanceType.STANDARD, observance).get, observance.as_ruleset(), ) ) return iters def _dst_observances( self, ) -> list[Iterable[tuple[datetime.datetime | datetime.date, _ObservanceInfo]]]: iters: list[ Iterable[tuple[datetime.datetime | datetime.date, _ObservanceInfo]] ] = [] for observance in self.daylight: iters.append( RecurIterable( _ObservanceInfo(_ObservanceType.DAYLIGHT, observance).get, observance.as_ruleset(), ) ) return iters def get_observance(self, value: datetime.datetime) -> _ObservanceInfo | None: """Return the specified observance for the specified date.""" if value.tzinfo is not None: raise ValueError("Start time must be in local time format") last_observance_info: _ObservanceInfo | None = None for dt_start, observance_info in self._observances(): if dt_start > value: return last_observance_info last_observance_info = observance_info return last_observance_info @model_validator(mode="after") def parse_required_timezoneinfo(self) -> Self: """Require at least one standard or daylight definition.""" standard = self.standard daylight = self.daylight if not standard and not daylight: raise ValueError("At least one standard or daylight definition is required") return self serialize_fields = field_serializer("*")(serialize_field) # type: ignore[pydantic-field] class IcsTimezoneInfo(datetime.tzinfo): """An implementation of tzinfo based on an ICS Timezone. This class is used to provide a tzinfo object for any datetime object used within a calendar. An rfc5545 calendar is an unambiguous definition of a calendar, and as a result, must encode all timezone information used in the calendar, hence this class. """ def __init__(self, timezone: Timezone) -> None: """Initialize IcsTimezoneInfo.""" self._timezone = timezone def __deepcopy__(self, memo: Any) -> IcsTimezoneInfo: """Return a deep copy of the timezone object.""" return IcsTimezoneInfo( timezone=copy.deepcopy(self._timezone, memo), ) @classmethod def from_timezone(cls, timezone: Timezone) -> IcsTimezoneInfo: """Create a new instance of an IcsTimezoneInfo.""" return cls(timezone) def utcoffset(self, dt: datetime.datetime | None) -> datetime.timedelta: """Return offset of local time from UTC, as a timedelta object.""" if not dt or not (obs := self._get_observance(dt)): return _ZERO return obs.observance.tz_offset_to.offset def tzname(self, dt: datetime.datetime | None) -> str | None: """Return the time zone name for the datetime as a sorting.""" if ( not dt or not (obs := self._get_observance(dt)) or not obs.observance.tz_name ): return None return obs.observance.tz_name[0] def dst(self, dt: datetime.datetime | None) -> datetime.timedelta | None: """Return the daylight saving time (DST) adjustment, if applicable.""" if ( not dt or not (obs := self._get_observance(dt)) or obs.observance_type != _ObservanceType.DAYLIGHT ): return _ZERO return obs.observance.tz_offset_to.offset - obs.observance.tz_offset_from.offset def _get_observance(self, value: datetime.datetime) -> _ObservanceInfo | None: return self._timezone.get_observance(value.replace(tzinfo=None)) def __str__(self) -> str: """A string representation of the timezone object.""" return self._timezone.tz_id def __repr__(self) -> str: """A string representation of the timezone object.""" return f"{self.__class__.__name__}({self._timezone.tz_id})" class TimezoneModel(ComponentModel): """A parser of a calendar that just parses timezone data. This exists so that we can parse timezone information in a first pass then propagate that information down to child objects when parsing the rest of the calendar. This is so we can do one pass on parsing events and timezone information at once, rather than deferring to later. """ timezones: list[Timezone] = Field(alias="vtimezone", default_factory=list) """Timezones associated with this calendar.""" allenporter-ical-fe8800b/ical/todo.py000066400000000000000000000374041510550726100176260ustar00rootroot00000000000000"""A grouping of component properties that describe a to-do. A todo component can represent an item of work assigned to an individual such as "turn in a travel expense today". A todo component without a start date or due date (or duration) specifies a to-do that will be associated with each successive calendar date until it is completed. """ from __future__ import annotations from collections.abc import Iterable import datetime import enum from typing import Annotated, Any, Optional, Self, Union import logging from pydantic import BeforeValidator, Field, field_serializer, model_validator from ical.types.data_types import serialize_field from .alarm import Alarm from .component import ( ComponentModel, validate_duration_unit, validate_until_dtstart, validate_recurrence_dates, ) from .exceptions import CalendarParseError from .iter import RulesetIterable, as_rrule from .parsing.property import ParsedProperty from .timespan import Timespan from .types import ( CalAddress, Classification, Geo, Priority, Recur, RecurrenceId, RequestStatus, Uri, RelatedTo, ) from .util import ( dtstamp_factory, normalize_datetime, parse_date_and_datetime, parse_date_and_datetime_list, uid_factory, local_timezone, ) _LOGGER = logging.getLogger(__name__) class TodoStatus(str, enum.Enum): """Status or confirmation of the to-do.""" NEEDS_ACTION = "NEEDS-ACTION" COMPLETED = "COMPLETED" IN_PROCESS = "IN-PROCESS" CANCELLED = "CANCELLED" class Todo(ComponentModel): """A calendar todo component.""" dtstamp: Union[datetime.date, datetime.datetime] = Field( default_factory=dtstamp_factory ) """Specifies the date and time the item was created.""" uid: str = Field(default_factory=lambda: uid_factory()) """A globally unique identifier for the item.""" attendees: list[CalAddress] = Field(alias="attendee", default_factory=list) """Specifies participants in a group-scheduled calendar.""" categories: list[str] = Field(default_factory=list) """Defines the categories for an item. Specifies a category or subtype. Can be useful for searching for a particular type of item. """ classification: Optional[Classification] = Field(alias="class", default=None) """An access classification for a calendar to-do item. This provides a method of capturing the scope of access of a calendar, in conjunction with an access control system. """ comment: list[str] = Field(default_factory=list) """Specifies a comment to the calendar user.""" completed: Optional[datetime.datetime] = None contacts: list[str] = Field(alias="contact", default_factory=list) """Contact information associated with the item.""" created: Optional[datetime.datetime] = None description: Optional[str] = None """A more complete description of the item than provided by the summary.""" # Has alias of 'start' dtstart: Annotated[ Union[datetime.date, datetime.datetime, None], BeforeValidator(parse_date_and_datetime), ] = None """The start time or start day of the item.""" due: Annotated[ Union[datetime.date, datetime.datetime, None], BeforeValidator(parse_date_and_datetime), ] = None duration: Optional[datetime.timedelta] = None """The duration of the item as an alternative to an explicit end date/time.""" geo: Optional[Geo] = None """Specifies a latitude and longitude global position for the activity.""" last_modified: Optional[datetime.datetime] = Field( alias="last-modified", default=None ) location: Optional[str] = None """Defines the intended venue for the activity defined by this item.""" organizer: Optional[CalAddress] = None """The organizer of a group-scheduled calendar entity.""" percent: Optional[int] = None priority: Optional[Priority] = None """Defines the relative priority of the todo item.""" recurrence_id: Optional[RecurrenceId] = Field(default=None, alias="recurrence-id") """Defines a specific instance of a recurring item. The full range of items specified by a recurrence set is referenced by referring to just the uid. The `recurrence_id` allows reference of an individual instance within the recurrence set. """ related_to: list[RelatedTo] = Field(alias="related-to", default_factory=list) """Used to represent a relationship or reference between events.""" request_status: Optional[RequestStatus] = Field( default=None, alias="request-status", ) rrule: Optional[Recur] = None """A recurrence rule specification. Defines a rule for specifying a repeated event. The recurrence set is the complete set of recurrence instances for a calendar component (based on rrule, rdate, exdate). The recurrence set is generated by gathering the rrule and rdate properties then excluding any times specified by exdate. The recurrence is generated with the dtstart property defining the first instance of the recurrence set. Typically a dtstart should be specified with a date local time and timezone to make sure all instances have the same start time regardless of time zone changing. """ rdate: Annotated[ list[Union[datetime.date, datetime.datetime]], BeforeValidator(parse_date_and_datetime_list), ] = Field(default_factory=list) """Defines the list of date/time values for recurring events. Can appear along with the rrule property to define a set of repeating occurrences of the event. The recurrence set is the complete set of recurrence instances for a calendar component (based on rrule, rdate, exdate). The recurrence set is generated by gathering the rrule and rdate properties then excluding any times specified by exdate. """ exdate: Annotated[ list[Union[datetime.date, datetime.datetime]], BeforeValidator(parse_date_and_datetime_list), ] = Field(default_factory=list) """Defines the list of exceptions for recurring events. The exception dates are used in computing the recurrence set. The recurrence set is the complete set of recurrence instances for a calendar component (based on rrule, rdate, exdate). The recurrence set is generated by gathering the rrule and rdate properties then excluding any times specified by exdate. """ sequence: Optional[int] = None """The revision sequence number in the calendar component. When an event is created, its sequence number is 0. It is monotonically incremented by the organizer's calendar user agent every time a significant revision is made to the calendar event. """ status: Optional[TodoStatus] = None """Defines the overall status or confirmation of the event. In a group-scheduled calendar, used by the organizer to provide a confirmation of the event to attendees. """ summary: Optional[str] = None """Defines a short summary or subject for the event.""" url: Optional[Uri] = None """Defines a url associated with the item. May convey a location where a more dynamic rendition of the item can be found. """ alarms: list[Alarm] = Field(alias="valarm", default_factory=list) extras: list[ParsedProperty] = Field(default_factory=list) def __init__(self, **data: Any) -> None: """Initialize Todo.""" if "start" in data: data["dtstart"] = data.pop("start") super().__init__(**data) @property def start(self) -> datetime.datetime | datetime.date | None: """Return the start time for the todo.""" return self.dtstart @property def start_datetime(self) -> datetime.datetime | None: """Return the todos start as a datetime.""" if not self.dtstart: return None return normalize_datetime(self.dtstart).astimezone(tz=datetime.timezone.utc) @property def end(self) -> datetime.datetime | datetime.date | None: """Return due if it's defined, or dtstart + duration if they're defined. RFC5545 doesn't define end time for other cases but this method implements the same rules as the one on VEVENT: if dtstart is a date, the next day is returned, otherwise the dtstart is returned. """ if self.due: return self.due if self.duration is not None: assert self.dtstart is not None return self.dtstart + self.duration if type(self.dtstart) is datetime.date: return self.dtstart + datetime.timedelta(days=1) # whenever dtstart is not None, end is not None return self.dtstart @property def computed_duration(self) -> datetime.timedelta: """Return the event duration. If duration is set, return it; if dtstart is set, take due (set or calculated) and return the difference. Otherwise return 1 day. If dtstart is a datetime and neither due nor duration is set, due is assumed to be equal to dtstart and the result is zero.""" if self.duration: return self.duration if self.dtstart: assert self.end return self.end - self.dtstart return datetime.timedelta(days=1) def is_due(self, tzinfo: datetime.tzinfo | None = None) -> bool: """Return true if the todo is due.""" if tzinfo is None: tzinfo = local_timezone() now = datetime.datetime.now(tz=tzinfo) due = self.end return due is not None and normalize_datetime(due, tzinfo) < now @property def timespan(self) -> Timespan: """Return a timespan representing the item start and due date.""" return self.timespan_of(local_timezone()) def timespan_of(self, tzinfo: datetime.tzinfo) -> Timespan: """Return a timespan representing the item start and due date or start and duration if it's set.""" dtstart = self.dtstart dtend = self.end if dtstart is None: if dtend is None: # A component without the DTSTART or DUE specifies a to-do that # will be associated with each successive calendar date, until # it is completed. dtstart = datetime.datetime.now(tzinfo).date() dtend = dtstart + datetime.timedelta(days=1) else: # Component with a DTSTART but no DUE date will be sorted next # to the due date. dtstart = dtend elif dtend is None: # Component with a DTSTART but not DUE date will be sorted next to the start date dtend = dtstart return Timespan.of( normalize_datetime(dtstart, tzinfo), normalize_datetime(dtend, tzinfo) ) @property def recurring(self) -> bool: """Return true if this Todo is recurring. A recurring event is typically evaluated specially on the list. The data model has a single todo, but the timeline evaluates the recurrence to expand and copy the event to multiple places on the timeline using `as_rrule`. """ if self.rrule or self.rdate: return True return False def as_rrule(self) -> Iterable[datetime.datetime | datetime.date] | None: """Return an iterable containing the occurrences of a recurring todo. A recurring todo is typically evaluated specially on the todo list. The data model has a single todo item, but the timeline evaluates the recurrence to expand and copy the item to multiple places on the timeline. This is only valid for events where `recurring` is True. """ if not self.rrule and not self.rdate: return None if not self.due and not self.duration: raise CalendarParseError("Event must have a due date or duration to be recurring") return as_rrule(self.rrule, self.rdate, self.exdate, self.dtstart) _validate_until_dtstart = model_validator(mode="after")(validate_until_dtstart) _validate_recurrence_dates = model_validator(mode="after")( validate_recurrence_dates ) _validate_duration_unit = model_validator(mode="after")(validate_duration_unit) @model_validator(mode="after") def _validate_one_due_or_duration(self) -> Self: """Validate that only one of duration or end date may be set.""" if self.due and self.duration: raise ValueError("Only one of due or duration may be set.") return self @model_validator(mode="after") def _validate_duration_requires_start(self) -> Self: """Validate that a duration requires the dtstart.""" if self.duration and not self.dtstart: raise ValueError("Duration requires that dtstart is specified") return self @model_validator(mode="after") def _validate_date_types(self) -> Self: """Validate and repair due vs start values to ensure they are the same date or datetime type.""" dtstart = self.dtstart due = self.due if not dtstart or not due: return self if isinstance(due, datetime.datetime): if not isinstance(dtstart, datetime.datetime): _LOGGER.debug( "Repairing unexpected dtstart value '%s' as date with due value '%s' as datetime", dtstart, due, ) self.dtstart = datetime.datetime.combine( dtstart, datetime.time.min, tzinfo=due.tzinfo ) elif isinstance(due, datetime.date): if isinstance(dtstart, datetime.datetime): _LOGGER.debug( "Repairing unexpected dtstart value '%s' as date with due value '%s' as datetime", dtstart, due, ) self.dtstart = dtstart.date() _LOGGER.debug("values=%s", self) return self @model_validator(mode="after") def _validate_datetime_timezone(self) -> Self: """Validate that start and due values have the same timezone information.""" if ( not (dtstart := self.dtstart) or not (due := self.due) or not isinstance(dtstart, datetime.datetime) or not isinstance(due, datetime.datetime) ): return self if dtstart.tzinfo is None and due.tzinfo is not None: raise ValueError(f"Expected due datetime value in localtime but was {due}") if dtstart.tzinfo is not None and due.tzinfo is None: raise ValueError(f"Expected due datetime with timezone but was {due}") return self @model_validator(mode="after") def _validate_due_later(self) -> Self: """Validate that the due property is later than dtstart.""" if not (due := self.due) or not (dtstart := self.dtstart): return self if due <= dtstart: _LOGGER.debug( "Due date %s is earlier than start date %s, adjusting start date", due, dtstart, ) self.dtstart = due - datetime.timedelta(days=1) return self @classmethod def _parse_single_property(cls, field_type: type, prop: ParsedProperty) -> Any: """Parse an individual field as a single type.""" try: return super()._parse_single_property(field_type, prop) except ValueError as err: if ( prop.name == "dtstart" and field_type == datetime.datetime and prop.params is not None ): _LOGGER.debug( "Applying todo dtstart repair for invalid timezone; Removing dtstart", ) return None raise err serialize_fields = field_serializer("*")(serialize_field) # type: ignore[pydantic-field] allenporter-ical-fe8800b/ical/types/000077500000000000000000000000001510550726100174435ustar00rootroot00000000000000allenporter-ical-fe8800b/ical/types/__init__.py000066400000000000000000000020451510550726100215550ustar00rootroot00000000000000"""Library for parsing rfc5545 Property Value Data Types and Properties.""" # Import all types for the registry from . import integer # noqa: F401 from . import boolean, date, date_time, duration # noqa: F401 from . import float as float_pkg # noqa: F401 from .cal_address import CalAddress, Role, CalendarUserType, ParticipationStatus from .const import Classification from .geo import Geo from .period import FreeBusyType, Period from .priority import Priority from .recur import Frequency, Range, Recur, RecurrenceId, Weekday, WeekdayValue from .relation import RelatedTo, RelationshipType from .request_status import RequestStatus from .uri import Uri from .utc_offset import UtcOffset __all__ = [ "CalAddress", "CalendarUserType", "Classification", "Frequency", "FreeBusyType", "Geo", "Period", "Priority", "Range", "Recur", "RecurrenceId", "RelatedTo", "RelationshipType", "RequestStatus", "Role", "ParticipationStatus", "UtcOffset", "Uri", "Weekday", "WeekdayValue", ] allenporter-ical-fe8800b/ical/types/boolean.py000066400000000000000000000014531510550726100214370ustar00rootroot00000000000000"""Library for parsing and encoding BOOLEAN values.""" from ical.parsing.property import ParsedProperty from .data_types import DATA_TYPE @DATA_TYPE.register("BOOLEAN") class BooleanEncoder: """Encode a boolean ICS value.""" @classmethod def __property_type__(cls) -> type: return bool @classmethod def __parse_property_value__(cls, prop: ParsedProperty) -> bool: """Parse an rfc5545 property into a boolean.""" if prop.value == "TRUE": return True if prop.value == "FALSE": return False raise ValueError(f"Unable to parse value as boolean: {prop}") @classmethod def __encode_property_value__(cls, value: bool) -> str: """Serialize boolean as an ICS value.""" return "TRUE" if value else "FALSE" allenporter-ical-fe8800b/ical/types/cal_address.py000066400000000000000000000070051510550726100222630ustar00rootroot00000000000000"""Library for parsing and encoding CAL-ADDRESS values.""" from __future__ import annotations import dataclasses import enum import logging from typing import Any, Optional from pydantic import BaseModel, ConfigDict, Field, model_validator from ical.parsing.property import ParsedPropertyParameter from .data_types import DATA_TYPE, encode_model_property_params from .parsing import parse_parameter_values from .uri import Uri _LOGGER = logging.getLogger(__name__) class CalendarUserType(str, enum.Enum): """The type of calendar user.""" INDIVIDUAL = "INDIVIDUAL" GROUP = "GROUP" RESOURCE = "RESOURCE" ROOM = "ROOM" UNKNOWN = "UNKNOWN" class ParticipationStatus(str, enum.Enum): """Participation status for a calendar user.""" NEEDS_ACTION = "NEEDS-ACTION" ACCEPTED = "ACCEPTED" DECLINED = "DECLINED" # Additional statuses for Events and Todos TENTATIVE = "TENTATIVE" DELEGATED = "DELEGATED" # Additional status for TODOs COMPLETED = "COMPLETED" class Role(str, enum.Enum): """Role for the calendar user.""" CHAIR = "CHAIR" REQUIRED = "REQ-PARTICIPANT" OPTIONAL = "OPT-PARTICIPANT" NON_PARTICIPANT = "NON-PARTICIPANT" @DATA_TYPE.register("CAL-ADDRESS") class CalAddress(BaseModel): """A value type for a property that contains a calendar user address.""" uri: Uri = Field(alias="value") """The calendar user address as a uri.""" common_name: Optional[str] = Field(alias="CN", default=None) """The common name associated with the calendar user.""" user_type: Optional[str] = Field(alias="CUTYPE", default=None) """The type of calendar user specified by the property. Common values are defined in CalendarUserType, though also supports other values not known by this library so it uses a string. """ delegator: Optional[list[Uri]] = Field(alias="DELEGATED-FROM", default=None) """The users that have delegated their participation to this user.""" delegate: Optional[list[Uri]] = Field(alias="DELEGATED-TO", default=None) """The users to whom the user has delegated participation.""" directory_entry: Optional[Uri] = Field(alias="DIR", default=None) """Reference to a directory entry associated with the calendar user.""" member: Optional[list[Uri]] = Field(alias="MEMBER", default=None) """The group or list membership of the calendar user.""" status: Optional[str] = Field(alias="PARTSTAT", default=None) """The participation status for the calendar user.""" role: Optional[str] = Field(alias="ROLE", default=None) """The participation role for the calendar user.""" rsvp: Optional[bool] = Field(alias="RSVP", default=None) """Whether there is an expectation of a favor of a reply from the calendar user.""" sent_by: Optional[Uri] = Field(alias="SENT-BY", default=None) """Specifies the calendar user is acting on behalf of another user.""" language: Optional[str] = Field(alias="LANGUAGE", default=None) _parse_parameter_values = model_validator(mode="before")(parse_parameter_values) __parse_property_value__ = dataclasses.asdict @classmethod def __encode_property_value__(cls, model_data: dict[str, str]) -> str | None: return model_data.pop("value") @classmethod def __encode_property_params__( cls, model_data: dict[str, Any] ) -> list[ParsedPropertyParameter]: return encode_model_property_params(cls.model_fields, model_data) model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True) allenporter-ical-fe8800b/ical/types/const.py000066400000000000000000000011631510550726100211440ustar00rootroot00000000000000"""Constants and enums representing rfc5545 values.""" import enum from typing import Self from ical.parsing.property import ParsedProperty from .data_types import DATA_TYPE @DATA_TYPE.register("CLASS") class Classification(str, enum.Enum): """Defines the access classification for a calendar component.""" PUBLIC = "PUBLIC" PRIVATE = "PRIVATE" CONFIDENTIAL = "CONFIDENTIAL" @classmethod def __parse_property_value__(cls, prop: ParsedProperty) -> Self | None: """Parse value into enum.""" try: return cls(prop.value) except ValueError: return None allenporter-ical-fe8800b/ical/types/data_types.py000066400000000000000000000154121510550726100221550ustar00rootroot00000000000000"""Library for parsing and encoding rfc5545 types.""" from __future__ import annotations import logging from collections.abc import Callable from typing import Any, Protocol, TypeVar, get_origin from pydantic import BaseModel, SerializationInfo from pydantic.fields import FieldInfo from ical.parsing.property import ParsedProperty, ParsedPropertyParameter from ical.util import get_field_type _LOGGER = logging.getLogger(__name__) T_TYPE = TypeVar("T_TYPE", bound=type) class DataType(Protocol): """Defines the protocol implemented by data types in this library. The methods defined in this protocol are all optional. """ @classmethod def __property_type__(cls) -> type: """Defines the python type to match, if different from the type itself.""" @classmethod def __parse_property_value__(cls, prop: ParsedProperty) -> type: """Parse the specified property value as a python type.""" @classmethod def __encode_property_json__(cls, value: Any) -> str | dict[str, str]: """Encode the property during pydantic serialization to object model.""" @classmethod def __encode_property_value__(cls, value: Any) -> str | None: """Encoded the property from the object model to the ics string value.""" @classmethod def __encode_property_params__( cls, model_data: dict[str, Any] ) -> list[ParsedPropertyParameter]: """Encode the property parameters from the object model.""" class Registry: """Registry of data types.""" def __init__( self, ) -> None: """Initialize Registry.""" self._items: dict[str, type] = {} self._parse_property_value: dict[type, Callable[[ParsedProperty], Any]] = {} self._parse_parameter_by_name: dict[str, Callable[[ParsedProperty], Any]] = {} self._encode_property_json: dict[ type, Callable[[Any], str | dict[str, str]] ] = {} self._encode_property_value: dict[type, Callable[[Any], str | None]] = {} self._encode_property_params: dict[ type, Callable[[dict[str, Any]], list[ParsedPropertyParameter]] ] = {} self._disable_value_param: set[type] = set() self._parse_order: dict[type, int] = {} def register( self, name: str | None = None, disable_value_param: bool = False, parse_order: int | None = None, ) -> Callable[[T_TYPE], T_TYPE]: """Return decorator to register a type. The name when specified is the Property Data Type value name. """ def decorator(func: T_TYPE) -> T_TYPE: """Register decorated function.""" if name: self._items[name] = func data_type = func if data_type_func := getattr(func, "__property_type__", None): data_type = data_type_func() if parse_property_value := getattr(func, "__parse_property_value__", None): self._parse_property_value[data_type] = parse_property_value if name: self._parse_parameter_by_name[name] = parse_property_value if encode_property_json := getattr(func, "__encode_property_json__", None): self._encode_property_json[data_type] = encode_property_json if encode_property_value := getattr( func, "__encode_property_value__", None ): self._encode_property_value[data_type] = encode_property_value if encode_property_params := getattr( func, "__encode_property_params__", None ): self._encode_property_params[data_type] = encode_property_params if disable_value_param: self._disable_value_param |= set({data_type}) if parse_order: self._parse_order[data_type] = parse_order return func return decorator @property def parse_property_value(self) -> dict[type, Callable[[ParsedProperty], Any]]: """Registry of python types to functions to parse into pydantic model.""" return self._parse_property_value @property def parse_parameter_by_name(self) -> dict[str, Callable[[ParsedProperty], Any]]: """Registry based on data value type string name.""" return self._parse_parameter_by_name @property def encode_property_json(self) -> dict[type, Callable[[Any], str | dict[str, str]]]: """Registry of encoders run during pydantic json serialization.""" return self._encode_property_json @property def encode_property_value(self) -> dict[type, Callable[[Any], str | None]]: """Registry of encoders that run on the output data model to ics.""" return self._encode_property_value @property def encode_property_params( self, ) -> dict[type, Callable[[dict[str, Any]], list[ParsedPropertyParameter]]]: """Registry of property parameter encoders run on output data model.""" return self._encode_property_params @property def disable_value_param(self) -> set[type]: """Return set of types that do not allow VALUE overrides by component parsing.""" return self._disable_value_param @property def parse_order(self) -> dict[type, int]: """Return the parse ordering of the specified type.""" return self._parse_order DATA_TYPE: Registry = Registry() def encode_model_property_params( fields: dict[str, FieldInfo], model_data: dict[str, Any] ) -> list[ParsedPropertyParameter]: """Encode a pydantic model's parameters as property params.""" params = [] for name, field in fields.items(): key = field.alias or name if key == "value" or (values := model_data.get(key)) is None: continue annotation = get_field_type(field.annotation) origin = get_origin(annotation) if origin is not list: values = [values] if annotation is bool: encoder = DATA_TYPE.encode_property_value[bool] values = [encoder(value) for value in values] params.append(ParsedPropertyParameter(name=key, values=values)) return params def serialize_field(self: BaseModel, value: Any, info: SerializationInfo) -> Any: if not info.context or not info.context.get("ics"): return value if isinstance(value, list): res = [] for val in value: for base in val.__class__.__mro__[:-1]: if (func := DATA_TYPE.encode_property_json.get(base)) is not None: res.append(func(val)) break else: res.append(val) return res for base in value.__class__.__mro__[:-1]: if (func := DATA_TYPE.encode_property_json.get(base)) is not None: return func(value) return value allenporter-ical-fe8800b/ical/types/date.py000066400000000000000000000023421510550726100207330ustar00rootroot00000000000000"""Library for parsing and encoding DATE values.""" from __future__ import annotations import datetime import logging import re from ical.parsing.property import ParsedProperty from .data_types import DATA_TYPE _LOGGER = logging.getLogger(__name__) DATE_REGEX = re.compile(r"^([0-9]{8})$") @DATA_TYPE.register("DATE", parse_order=1) class DateEncoder: """Encode and decode an rfc5545 DATE and datetime.date.""" @classmethod def __property_type__(cls) -> type: return datetime.date @classmethod def __parse_property_value__(cls, prop: ParsedProperty) -> datetime.date | None: """Parse a rfc5545 into a datetime.date.""" if not (match := DATE_REGEX.fullmatch(prop.value)): raise ValueError(f"Expected value to match DATE pattern: '{prop.value}'") date_value = match.group(1) year = int(date_value[0:4]) month = int(date_value[4:6]) day = int(date_value[6:]) result = datetime.date(year, month, day) _LOGGER.debug("DateEncoder returned %s", result) return result @classmethod def __encode_property_json__(cls, value: datetime.date) -> str: """Serialize as an ICS value.""" return value.strftime("%Y%m%d") allenporter-ical-fe8800b/ical/types/date_time.py000066400000000000000000000076221510550726100217570ustar00rootroot00000000000000"""Library for parsing and encoding DATE-TIME types.""" from __future__ import annotations import datetime import logging import re import zoneinfo from typing import Any from ical.parsing.property import ParsedProperty, ParsedPropertyParameter from ical.compat import timezone_compat from ical.tzif import timezoneinfo from ical.exceptions import ParameterValueError from .data_types import DATA_TYPE _LOGGER = logging.getLogger(__name__) DATETIME_REGEX = re.compile(r"^([0-9]{8})T([0-9]{6})(Z)?$") TZID = "TZID" ATTR_VALUE = "VALUE" def parse_property_value( prop: ParsedProperty, allow_invalid_timezone: bool = False ) -> datetime.datetime: """Parse a rfc5545 into a datetime.datetime.""" if timezone_compat.is_allow_invalid_timezones_enabled(): allow_invalid_timezone = True if not (match := DATETIME_REGEX.fullmatch(prop.value)): raise ValueError(f"Expected value to match DATE-TIME pattern: {prop.value}") # Example: TZID=America/New_York:19980119T020000 timezone: datetime.tzinfo | None = None if param := prop.get_parameter(TZID): if param.values and (value := param.values[0]): if isinstance(value, datetime.tzinfo): timezone = value else: try: timezone = zoneinfo.ZoneInfo(value) except zoneinfo.ZoneInfoNotFoundError: try: timezone = timezoneinfo.read_tzinfo(value) except timezoneinfo.TimezoneInfoError: if allow_invalid_timezone: timezone = None else: raise ParameterValueError( f"Expected DATE-TIME TZID value '{value}' to be valid timezone" ) elif match.group(3): # Example: 19980119T070000Z timezone = datetime.timezone.utc # Example: 19980118T230000 date_value = match.group(1) year = int(date_value[0:4]) month = int(date_value[4:6]) day = int(date_value[6:]) time_value = match.group(2) hour = int(time_value[0:2]) minute = int(time_value[2:4]) second = int(time_value[4:6]) result = datetime.datetime(year, month, day, hour, minute, second, tzinfo=timezone) _LOGGER.debug("DateTimeEncoder returned %s", result) return result @DATA_TYPE.register("DATE-TIME", parse_order=2) class DateTimeEncoder: """Class to handle encoding for a datetime.datetime.""" @classmethod def __property_type__(cls) -> type: return datetime.datetime @classmethod def __parse_property_value__(cls, prop: ParsedProperty) -> datetime.datetime: """Parse a rfc5545 into a datetime.datetime.""" return parse_property_value(prop, allow_invalid_timezone=False) @classmethod def __encode_property_json__(cls, value: datetime.datetime) -> str | dict[str, str]: """Encode an ICS value during json serialization.""" if value.tzinfo is None: return value.strftime("%Y%m%dT%H%M%S") # Does not yet handle timezones and encoding property parameters if not value.utcoffset(): return value.strftime("%Y%m%dT%H%M%SZ") return { ATTR_VALUE: value.strftime("%Y%m%dT%H%M%S"), TZID: str(value.tzinfo), # Timezone key } @classmethod def __encode_property_value__(cls, value: str | dict[str, Any]) -> str | None: """Encode the ParsedProperty value.""" if isinstance(value, str): return value return value.get(ATTR_VALUE) @classmethod def __encode_property_params__( cls, value: str | dict[str, str] ) -> list[ParsedPropertyParameter]: """Encode parameters for the property value.""" if isinstance(value, dict) and (tzid := value.get(TZID)): return [ParsedPropertyParameter(name=TZID, values=[str(tzid)])] return [] allenporter-ical-fe8800b/ical/types/duration.py000066400000000000000000000045401510550726100216450ustar00rootroot00000000000000"""Library for parsing and encoding DURATION values.""" import datetime import re from ical.parsing.property import ParsedProperty from .data_types import DATA_TYPE DATE_PART = r"(\d+)D" TIME_PART = r"T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?" DATETIME_PART = f"(?:{DATE_PART})?(?:{TIME_PART})?" WEEKS_PART = r"(?:(\d+)W)?" DURATION_REGEX = re.compile(f"([-+]?)P(?:{WEEKS_PART}{DATETIME_PART})$") @DATA_TYPE.register("DURATION") class DurationEncoder: """Class that can encode DURATION values.""" @classmethod def __property_type__(cls) -> type: return datetime.timedelta @classmethod def __parse_property_value__(cls, prop: ParsedProperty) -> datetime.timedelta: """Parse a rfc5545 into a datetime.date.""" if not isinstance(prop, ParsedProperty): raise ValueError(f"Expected ParsedProperty but was {prop}") if not (match := DURATION_REGEX.fullmatch(prop.value)): raise ValueError(f"Expected value to match DURATION pattern: {prop.value}") sign, weeks, days, hours, minutes, seconds = match.groups() result: datetime.timedelta = datetime.timedelta( weeks=int(weeks or 0), days=int(days or 0), hours=int(hours or 0), minutes=int(minutes or 0), seconds=int(seconds or 0), ) if sign == "-": result = -result return result @classmethod def __encode_property_json__(cls, duration: datetime.timedelta) -> str: """Serialize a time delta as a DURATION ICS value.""" parts = [] if duration < datetime.timedelta(days=0): parts.append("-") duration = -duration parts.append("P") days = duration.days weeks = int(days / 7) days %= 7 if weeks > 0: parts.append(f"{weeks}W") if days > 0: parts.append(f"{days}D") if duration.seconds != 0: parts.append("T") seconds = duration.seconds hours = int(seconds / 3600) seconds %= 3600 if hours != 0: parts.append(f"{hours}H") minutes = int(seconds / 60) seconds %= 60 if minutes != 0: parts.append(f"{minutes}M") if seconds != 0: parts.append(f"{seconds}S") return "".join(parts) allenporter-ical-fe8800b/ical/types/float.py000066400000000000000000000007431510550726100211260ustar00rootroot00000000000000"""Library for parsing and encoding FLOAT values.""" from ical.parsing.property import ParsedProperty from .data_types import DATA_TYPE @DATA_TYPE.register("FLOAT") class FloatEncoder: """Encode a float ICS value.""" @classmethod def __property_type__(cls) -> type: return float @classmethod def __parse_property_value__(cls, prop: ParsedProperty) -> float: """Parse a rfc5545 property into a text value.""" return float(prop.value) allenporter-ical-fe8800b/ical/types/geo.py000066400000000000000000000015661510550726100205770ustar00rootroot00000000000000"""Library for parsing and encoding GEO values.""" from __future__ import annotations from dataclasses import dataclass from typing import Any from .data_types import DATA_TYPE from .text import TextEncoder @DATA_TYPE.register("GEO") @dataclass class Geo: """Information related to the global position for an activity.""" lat: float lng: float @classmethod def __parse_property_value__(cls, value: Any) -> Geo: """Parse a rfc5545 lat long geo values.""" parts = TextEncoder.__parse_property_value__(value).split(";", 2) if len(parts) != 2: raise ValueError(f"Value was not valid geo lat;long: {value}") return Geo(lat=float(parts[0]), lng=float(parts[1])) @classmethod def __encode_property_json__(cls, value: Geo) -> str: """Serialize as an ICS value.""" return f"{value.lat};{value.lng}" allenporter-ical-fe8800b/ical/types/integer.py000066400000000000000000000010441510550726100214510ustar00rootroot00000000000000"""Library for parsing and encoding INTEGER values.""" from typing import Any from ical.parsing.property import ParsedProperty from .data_types import DATA_TYPE @DATA_TYPE.register("INTEGER") class IntEncoder: """Encode an int ICS value.""" @classmethod def __property_type__(cls) -> type: return int @classmethod def __parse_property_value__(cls, prop: Any) -> int: """Parse a rfc5545 int value.""" if isinstance(prop, ParsedProperty): return int(prop.value) return int(prop) allenporter-ical-fe8800b/ical/types/parsing.py000066400000000000000000000025451510550726100214660ustar00rootroot00000000000000"""Library for parsing rfc5545 types.""" from __future__ import annotations import logging from typing import Any, get_origin from pydantic import BaseModel from pydantic.fields import FieldInfo from ical.util import get_field_type _LOGGER = logging.getLogger(__name__) def _all_fields(cls: type[BaseModel]) -> dict[str, FieldInfo]: all_fields: dict[str, FieldInfo] = {} for name, model_field in cls.model_fields.items(): all_fields[name] = model_field if model_field.alias is not None: all_fields[model_field.alias] = model_field return all_fields def parse_parameter_values( cls: type[BaseModel], values: dict[str, Any] ) -> dict[str, Any]: """Convert property parameters to pydantic fields.""" _LOGGER.debug("parse_parameter_values=%s", values) if params := values.get("params"): all_fields = _all_fields(cls) for param in params: if not (field := all_fields.get(param["name"])): continue annotation = get_field_type(field.annotation) if get_origin(annotation) is list: values[param["name"]] = param["values"] else: if len(param["values"]) > 1: raise ValueError("Unexpected repeated property parameter") values[param["name"]] = param["values"][0] return values allenporter-ical-fe8800b/ical/types/period.py000066400000000000000000000114621510550726100213030ustar00rootroot00000000000000"""Library for parsing and encoding PERIOD values.""" import dataclasses import datetime import enum import logging from typing import Any, Optional, Self from pydantic import BaseModel, ConfigDict, Field, field_serializer, model_validator from ical.parsing.property import ParsedProperty, ParsedPropertyParameter from .data_types import DATA_TYPE, encode_model_property_params, serialize_field from .date_time import DateTimeEncoder from .duration import DurationEncoder from .parsing import parse_parameter_values _LOGGER = logging.getLogger(__name__) @DATA_TYPE.register("FBTYPE") class FreeBusyType(str, enum.Enum): """Specifies the free/busy time type.""" FREE = "FREE" """The time interval is free for scheduling.""" BUSY = "BUSY" """One or more events have been scheduled for the interval.""" BUSY_UNAVAILABLE = "BUSY-UNAVAILABLE" """The interval can not be scheduled.""" BUSY_TENTATIVE = "BUSY-TENTATIVE" """One or more events have been tentatively scheduled for the interval.""" @classmethod def __parse_property_value__(cls, prop: ParsedProperty) -> Self | None: """Parse value into enum.""" try: return cls(prop.value) except ValueError: return None @DATA_TYPE.register("PERIOD") class Period(BaseModel): """A value with a precise period of time.""" start: datetime.datetime """Start of the period of time.""" end: Optional[datetime.datetime] = None """End of the period of the time (duration is implicit).""" duration: Optional[datetime.timedelta] = None """Duration of the period of time (end time is implicit).""" # Context specific property parameters free_busy_type: Optional[FreeBusyType] = Field(alias="FBTYPE", default=None) """Specifies the free or busy time type.""" _parse_parameter_values = model_validator(mode="before")(parse_parameter_values) @property def end_value(self) -> datetime.datetime: """A computed end value based on either end or duration.""" if self.end: return self.end if not self.duration: raise ValueError("Invalid period missing both end and duration") return self.start + self.duration @model_validator(mode="before") @classmethod def parse_period_fields(cls, values: dict[str, Any]) -> dict[str, Any]: """Parse a rfc5545 priority value.""" if not (value := values.pop("value", None)): return values parts = value.split("/") if len(parts) != 2: raise ValueError(f"Period did not have two time values: {value}") try: start = DateTimeEncoder.__parse_property_value__( ParsedProperty(name="ignored", value=parts[0]) ) except ValueError as err: _LOGGER.debug("Failed to parse start date as date time: %s", parts[0]) raise err values["start"] = start try: end = DateTimeEncoder.__parse_property_value__( ParsedProperty(name="ignored", value=parts[1]) ) except ValueError: pass else: values["end"] = end return values try: duration = DurationEncoder.__parse_property_value__( ParsedProperty(name="ignored", value=parts[1]) ) except ValueError as err: raise err values["duration"] = duration return values @classmethod def __parse_property_value__(cls, prop: ParsedProperty) -> dict[str, str]: """Convert the property into a dictionary for pydantic model.""" return dataclasses.asdict(prop) @classmethod def __encode_property_value__(cls, model_data: dict[str, Any]) -> str: """Encode property value.""" if not (start := model_data.pop("start", None)): raise ValueError(f"Invalid period object missing start: {model_data}") end = model_data.pop("end", None) duration = model_data.pop("duration", None) if not end and not duration: raise ValueError( f"Invalid period missing both end and duration: {model_data}" ) # End and duration are already encoded values if end: return "/".join([start, end]) return "/".join([start, duration]) @classmethod def __encode_property_params__( cls, model_data: dict[str, Any] ) -> list[ParsedPropertyParameter]: return encode_model_property_params( cls.model_fields, { k: v for k, v in model_data.items() if k not in ("end", "duration", "start") }, ) model_config = ConfigDict(populate_by_name=True) serialize_fields = field_serializer("*")(serialize_field) # type: ignore[pydantic-field] allenporter-ical-fe8800b/ical/types/priority.py000066400000000000000000000016251510550726100217020ustar00rootroot00000000000000"""Parser for the PRIORITY type.""" from typing import Any, Self from pydantic import GetCoreSchemaHandler from pydantic_core import CoreSchema, core_schema from ical.parsing.property import ParsedProperty from .integer import IntEncoder class Priority(int): """Defines relative priority for a calendar component.""" @classmethod def parse_priority(cls, value: ParsedProperty | int) -> Self: """Parse a rfc5545 into a text value.""" priority = IntEncoder.__parse_property_value__(value) if priority < 0 or priority > 9: raise ValueError("Expected priority between 0-9") return cls(priority) @classmethod def __get_pydantic_core_schema__( cls, source_type: Any, handler: GetCoreSchemaHandler ) -> CoreSchema: return core_schema.no_info_before_validator_function( cls.parse_priority, handler(source_type) ) allenporter-ical-fe8800b/ical/types/recur.py000066400000000000000000000321201510550726100211330ustar00rootroot00000000000000"""Implementation of recurrence rules for calendar components. This library handles the parsing of the rules from a pydantic model and relies on the `dateutil.rrule` implementation for the actual implementation of the date and time repetition. Many existing libraries, such as UI components, support directly creating or modifying recurrence rule strings. This is an example of creating a recurring weekly event using a string RRULE, then printing out all of the start dates of the expanded event timeline: ```python from ical.calendar import Calendar from ical.event import Event from ical.types.recur import Recur event = Event( summary='Monday meeting', start="2022-08-29T09:00:00", end="2022-08-29T09:30:00", recur=Recur.from_rrule("FREQ=WEEKLY;COUNT=3") ) calendar = Calendar(events=[event]) print([ev.dtstart for ev in list(calendar.timeline)]) ``` The above example will output something like this: ``` [datetime.datetime(2022, 8, 29, 9, 0), datetime.datetime(2022, 9, 5, 9, 0), datetime.datetime(2022, 9, 12, 9, 0)] ``` """ from __future__ import annotations import datetime import enum import logging import re from dataclasses import dataclass from typing import Annotated, Any, Literal, Optional, Union from dateutil import rrule from pydantic import ( BaseModel, BeforeValidator, ConfigDict, Field, GetCoreSchemaHandler, field_serializer, ) from pydantic_core import CoreSchema, core_schema from ical.parsing.property import ParsedProperty from ical.util import parse_date_and_datetime from .data_types import DATA_TYPE, serialize_field from .date import DateEncoder from .date_time import DateTimeEncoder _LOGGER = logging.getLogger(__name__) # Note: This can be StrEnum in python 3.11 and higher class Weekday(str, enum.Enum): """Corresponds to a day of the week.""" SUNDAY = "SU" MONDAY = "MO" TUESDAY = "TU" WEDNESDAY = "WE" THURSDAY = "TH" FRIDAY = "FR" SATURDAY = "SA" def __str__(self) -> str: """Return string representation.""" return self.value @dataclass class WeekdayValue: """Holds a weekday value and optional occurrence value.""" weekday: Weekday """Day of the week value.""" occurrence: Optional[int] = None """The occurrence value indicates the nth occurrence. Indicates the nth occurrence of a specific day within the MONTHLY or YEARLY "RRULE". For example +1 represents the first Monday of the month, or -1 represents the last Monday of the month. """ def __str__(self) -> str: """Return the WeekdayValue as an encoded string.""" return f"{self.occurrence or ''}{self.weekday}" def as_rrule_weekday(self) -> rrule.weekday: """Convert the occurrence to a weekday value.""" wd = RRULE_WEEKDAY[self.weekday] if self.occurrence is None: return wd return wd(self.occurrence) class Frequency(str, enum.Enum): """Type of recurrence rule. Frequencies SECONDLY, MINUTELY, HOURLY, YEARLY are not supported. """ DAILY = "DAILY" """Repeating events based on an interval of a day or more.""" WEEKLY = "WEEKLY" """Repeating events based on an interval of a week or more.""" MONTHLY = "MONTHLY" """Repeating events based on an interval of a month or more.""" YEARLY = "YEARLY" """Repeating events based on an interval of a year or more.""" class Range(str, enum.Enum): """Specifies an effective range of recurrence instances for a recurrence id. This is used when modifying a recurrence rule and specifying that the action applies to all events following the specified event. """ NONE = "NONE" """No range is specified, just a single instance.""" THIS_AND_FUTURE = "THISANDFUTURE" """The range of the recurrence identifier and all subsequent values.""" @DATA_TYPE.register(disable_value_param=True) class RecurrenceId(str): """Identifies a specific instance of a recurring calendar component. A property type used in conjunction with the "UID" and "SEQUENCE" properties to specify a specific instance of a recurrent calendar component. The full range of a recurrence set is referenced by the "UID". The recurrence id can reference a specific instance within the set. """ @classmethod def to_value(cls, recurrence_id: str) -> datetime.datetime | datetime.date: """Convert a string RecurrenceId into a date or time value.""" errors = [] try: date_value = DateEncoder.__parse_property_value__( ParsedProperty(name="", value=recurrence_id) ) if date_value: return date_value except ValueError as err: errors.append(err) try: date_time_value = DateTimeEncoder.__parse_property_value__( ParsedProperty(name="", value=recurrence_id) ) if date_time_value: return date_time_value except ValueError as err: errors.append(err) raise ValueError(f"Unable to parse date/time value: {errors}") @classmethod def __parse_property_value__(cls, value: Any) -> RecurrenceId: """Parse a calendar user address.""" if isinstance(value, ParsedProperty): value = cls._parse_value(value.value) if isinstance(value, str): value = cls._parse_value(value) if isinstance(value, datetime.datetime): value = DateTimeEncoder.__encode_property_json__(value) elif isinstance(value, datetime.date): value = DateEncoder.__encode_property_json__(value) else: value = str(value) return RecurrenceId(value) @classmethod def _parse_value(cls, value: str) -> datetime.datetime | datetime.date | str: try: return cls.to_value(value) except ValueError: pass return str(value) @classmethod def __get_pydantic_core_schema__( cls, source_type: Any, handler: GetCoreSchemaHandler ) -> CoreSchema: return core_schema.no_info_before_validator_function( cls.__parse_property_value__, handler(source_type) ) RRULE_FREQ: dict[Frequency, Literal[0, 1, 2, 3]] = { Frequency.DAILY: rrule.DAILY, Frequency.WEEKLY: rrule.WEEKLY, Frequency.MONTHLY: rrule.MONTHLY, Frequency.YEARLY: rrule.YEARLY, } RRULE_WEEKDAY = { Weekday.MONDAY: rrule.MO, Weekday.TUESDAY: rrule.TU, Weekday.WEDNESDAY: rrule.WE, Weekday.THURSDAY: rrule.TH, Weekday.FRIDAY: rrule.FR, Weekday.SATURDAY: rrule.SA, Weekday.SUNDAY: rrule.SU, } WEEKDAY_REGEX = re.compile(r"([-+]?[0-9]*)([A-Z]+)") RecurInputDict = dict[ str, Union[datetime.datetime, datetime.date, str, list[str], list[dict[str, str]], None], ] @DATA_TYPE.register("RECUR") class Recur(BaseModel): """A type used to identify properties that contain a recurrence rule specification. The by properties reduce or limit the number of occurrences generated. Only by day of the week and by month day are supported. Parts of rfc5545 recurrence spec not supported: By second, minute, hour By yearday, weekno, month Wkst rules are Negative "by" rules. """ freq: Frequency until: Annotated[ Union[datetime.date, datetime.datetime, None], BeforeValidator(parse_date_and_datetime), ] = None """The inclusive end date of the recurrence, or the last instance.""" count: Optional[int] = None """The number of occurrences to bound the recurrence.""" interval: int = 1 """Interval at which the recurrence rule repeats.""" by_weekday: list[WeekdayValue] = Field(alias="byday", default_factory=list) """Supported days of the week.""" by_month_day: list[int] = Field(alias="bymonthday", default_factory=list) """Days of the month between 1 to 31.""" by_month: list[int] = Field(alias="bymonth", default_factory=list) """Month number between 1 and 12.""" by_setpos: list[int] = Field(alias="bysetpos", default_factory=list) """Values that corresponds to the nth occurrence within the set of instances.""" def as_rrule(self, dtstart: datetime.datetime | datetime.date) -> rrule.rrule: """Create a dateutil rrule for the specified event.""" if (freq := RRULE_FREQ.get(self.freq)) is None: raise ValueError(f"Unsupported frequency in rrule: {self.freq}") byweekday: list[rrule.weekday] | None = None if self.by_weekday: byweekday = [weekday.as_rrule_weekday() for weekday in self.by_weekday] return rrule.rrule( freq=freq, dtstart=dtstart, interval=self.interval, count=self.count, until=self.until, byweekday=byweekday, bymonthday=self.by_month_day if self.by_month_day else None, bymonth=self.by_month if self.by_month else None, bysetpos=self.by_setpos, cache=True, ) def as_rrule_str(self) -> str: """Return the Recur instance as an RRULE string.""" return self.__encode_property_value__( self.model_dump(by_alias=True, exclude_none=True, exclude_defaults=True) ) @classmethod def from_rrule(cls, rrule_str: str) -> Recur: """Create a Recur object from an RRULE string.""" return Recur.model_validate(cls.__parse_property_value__(rrule_str)) model_config = ConfigDict(validate_assignment=True, populate_by_name=True) serialize_fields = field_serializer("*")(serialize_field) # type: ignore[pydantic-field] @classmethod def __encode_property_value__(cls, data: dict[str, Any]) -> str: """Encode the recurrence rule in ICS format.""" result = [] for key, value in data.items(): # Need to encode based on field type also using json encoders if key in ("bymonthday", "bymonth", "bysetpos"): if not value: continue value = ",".join([str(val) for val in value]) elif key == "byday": values = [] for weekday_value in value: if isinstance(weekday_value, dict): weekday_value = WeekdayValue(**weekday_value) values.append(str(weekday_value)) value = ",".join(values) elif isinstance(value, datetime.datetime): value = DateTimeEncoder.__encode_property_json__(value) elif isinstance(value, datetime.date): value = DateEncoder.__encode_property_json__(value) elif isinstance(value, enum.Enum): value = value.name elif key == "interval" and value == 1: # Filter not None default value continue if not value: continue result.append(f"{key.upper()}={value}") return ";".join(result) @classmethod def __parse_property_value__( # pylint: disable=too-many-branches cls, prop: Any ) -> RecurInputDict: """Parse the recurrence rule text as a dictionary as Pydantic input. An input rule like 'FREQ=YEARLY;BYMONTH=4' is converted into dictionary. """ if isinstance(prop, str): value = prop elif not isinstance(prop, ParsedProperty): raise ValueError(f"Expected recurrence rule as ParsedProperty: {prop}") else: value = prop.value result: RecurInputDict = {} for part in value.split(";"): if "=" not in part: raise ValueError( f"Recurrence rule had unexpected format missing '=': {prop.value}" ) key, value = part.split("=") key = key.lower() if key == "until": new_value: datetime.datetime | datetime.date | None try: new_value = DateTimeEncoder.__parse_property_value__( ParsedProperty(name="ignored", value=value) ) except ValueError: new_value = DateEncoder.__parse_property_value__( ParsedProperty(name="ignored", value=value) ) result[key] = new_value elif key in ("bymonthday", "bymonth", "bysetpos"): result[key] = value.split(",") elif key == "byday": # Build inputs for WeekdayValue dataclass results: list[dict[str, str]] = [] for value in value.split(","): if not (match := WEEKDAY_REGEX.fullmatch(value)): raise ValueError( f"Expected value to match UTC-OFFSET pattern: {value}" ) occurrence, weekday = match.groups() weekday_result = {"weekday": weekday} if occurrence: weekday_result["occurrence"] = occurrence results.append(weekday_result) result[key] = results else: result[key] = value return result allenporter-ical-fe8800b/ical/types/relation.py000066400000000000000000000046001510550726100216320ustar00rootroot00000000000000"""Implementation of the RELATED-TO property.""" import enum from dataclasses import dataclass from typing import Any, Self import logging from pydantic import model_validator from ical.parsing.property import ParsedProperty, ParsedPropertyParameter from .data_types import DATA_TYPE from .parsing import parse_parameter_values @DATA_TYPE.register("RELATIONSHIP-TYPE") class RelationshipType(str, enum.Enum): """Type of hierarchical relationship associated with the calendar component.""" PARENT = "PARENT" """Parent relationship - Default.""" CHILD = "CHILD" """Child relationship.""" SIBBLING = "SIBBLING" """Sibling relationship.""" @classmethod def __parse_property_value__(cls, prop: ParsedProperty) -> Self | None: """Parse value into enum.""" try: return cls(prop.value) except ValueError: return None @DATA_TYPE.register("RELATED-TO") @dataclass class RelatedTo: """Used to represent a relationship or reference between one calendar component and another.""" uid: str """The value of the related-to property is the persistent, globally unique identifier of another calendar component.""" reltype: RelationshipType = RelationshipType.PARENT """Indicate the type of hierarchical relationship associated with the calendar component specified by the uid.""" @classmethod def __parse_property_value__(cls, prop: Any) -> dict[str, Any]: """Parse a rfc5545 int value.""" logging.info("prop=%s", prop) if isinstance(prop, ParsedProperty): data: dict[str, Any] = {"uid": prop.value} for param in prop.params or (): if len(param.values) > 1: raise ValueError("Expected only one value for RELATED-TO parameter") data[param.name] = param.values[0] return data return {"uid": prop} _parse_parameter_values = model_validator(mode="before")(parse_parameter_values) @classmethod def __encode_property_value__(cls, model_data: dict[str, str]) -> str | None: return model_data.pop("uid") @classmethod def __encode_property_params__( cls, model_data: dict[str, Any] ) -> list[ParsedPropertyParameter]: if "reltype" not in model_data: return [] return [ParsedPropertyParameter(name="RELTYPE", values=[model_data["reltype"]])] allenporter-ical-fe8800b/ical/types/request_status.py000066400000000000000000000023421510550726100231110ustar00rootroot00000000000000"""Implementation of the REQUEST-STATUS property.""" from __future__ import annotations from dataclasses import dataclass from typing import Any, Optional from .data_types import DATA_TYPE from .text import TextEncoder @dataclass @DATA_TYPE.register() class RequestStatus: """Status code returned for a scheduling request.""" statcode: float statdesc: str exdata: Optional[str] = None @classmethod def __parse_property_value__(cls, value: Any) -> RequestStatus: """Parse a rfc5545 request status value.""" parts = TextEncoder.__parse_property_value__(value).split(";") if len(parts) < 2 or len(parts) > 3: raise ValueError(f"Value was not valid Request Status: {value}") exdata: str | None = None if len(parts) == 3: exdata = parts[2] return RequestStatus( statcode=float(parts[0]), statdesc=parts[1], exdata=exdata, ) @classmethod def __encode_property_json__(cls, value: RequestStatus) -> str: """Encoded RequestStatus as an ICS property.""" result = f"{value.statcode};{value.statdesc}" if value.exdata: result += f";{value.exdata}" return result allenporter-ical-fe8800b/ical/types/text.py000066400000000000000000000020231510550726100207760ustar00rootroot00000000000000"""Library for parsing TEXT values.""" from ical.parsing.property import ParsedProperty from .data_types import DATA_TYPE UNESCAPE_CHAR = {"\\\\": "\\", "\\;": ";", "\\,": ",", "\\N": "\n", "\\n": "\n"} ESCAPE_CHAR = {v: k for k, v in UNESCAPE_CHAR.items()} @DATA_TYPE.register("TEXT") class TextEncoder: """Encode an rfc5545 TEXT value.""" @classmethod def __property_type__(cls) -> type: return str @classmethod def __parse_property_value__(cls, prop: ParsedProperty) -> str: """Parse a rfc5545 into a text value.""" for key, vin in UNESCAPE_CHAR.items(): if key not in prop.value: continue prop.value = prop.value.replace(key, vin) return prop.value @classmethod def __encode_property_value__(cls, value: str) -> str: """Serialize text as an ICS value.""" for key, vin in ESCAPE_CHAR.items(): if key not in value: continue value = value.replace(key, vin) return value allenporter-ical-fe8800b/ical/types/uri.py000066400000000000000000000017451510550726100206230ustar00rootroot00000000000000"""Library for parsing and encoding URI values.""" from __future__ import annotations from typing import Any, Self from urllib.parse import urlparse from pydantic import GetCoreSchemaHandler from pydantic_core import CoreSchema, core_schema from ical.parsing.property import ParsedProperty from .data_types import DATA_TYPE @DATA_TYPE.register("URI") class Uri(str): """A value type for a property that contains a uniform resource identifier.""" @classmethod def __parse_property_value__(cls, value: ParsedProperty | str) -> Self: """Parse a calendar user address.""" if isinstance(value, ParsedProperty): value = value.value urlparse(value) return cls(value) @classmethod def __get_pydantic_core_schema__( cls, source_type: Any, handler: GetCoreSchemaHandler ) -> CoreSchema: return core_schema.no_info_before_validator_function( cls.__parse_property_value__, handler(source_type) ) allenporter-ical-fe8800b/ical/types/utc_offset.py000066400000000000000000000034361510550726100221640ustar00rootroot00000000000000"""Library for parsing and encoding UTC-OFFSET values.""" from __future__ import annotations import datetime import re from dataclasses import dataclass from typing import Any from ical.parsing.property import ParsedProperty from .data_types import DATA_TYPE UTC_OFFSET_REGEX = re.compile(r"^([-+]?)([0-9]{2})([0-9]{2})([0-9]{2})?$") @DATA_TYPE.register("UTC-OFFSET") @dataclass class UtcOffset: """Contains an offset from UTC to local time.""" offset: datetime.timedelta @classmethod def __parse_property_value__(cls, prop: Any) -> UtcOffset: """Parse a UTC Offset.""" if isinstance(prop, UtcOffset): return prop value = prop if isinstance(prop, ParsedProperty): value = prop.value if not (match := UTC_OFFSET_REGEX.fullmatch(value)): raise ValueError(f"Expected value to match UTC-OFFSET pattern: {value}") sign, hours, minutes, seconds = match.groups() result = datetime.timedelta( hours=int(hours or 0), minutes=int(minutes or 0), seconds=int(seconds or 0), ) if sign == "-": result = -result return UtcOffset(result) @classmethod def __encode_property_json__(cls, value: UtcOffset) -> str: """Serialize a time delta as a UTC-OFFSET ICS value.""" duration = value.offset parts = [] if duration < datetime.timedelta(days=0): parts.append("-") duration = -duration else: parts.append("+") seconds = duration.seconds hours = int(seconds / 3600) seconds %= 3600 parts.append(f"{hours:02}") minutes = int(seconds / 60) seconds %= 60 parts.append(f"{minutes:02}") return "".join(parts) allenporter-ical-fe8800b/ical/tzif/000077500000000000000000000000001510550726100172535ustar00rootroot00000000000000allenporter-ical-fe8800b/ical/tzif/README.md000066400000000000000000000030161510550726100205320ustar00rootroot00000000000000# tzif This python library provides time transitions for timezones. This library does not directly provide time zone data, and instead uses the same sources as `zoneinfo` which includes the system timezone database or the `tzdata` package. ## Background iCalendar files are fully specified, and there is no ambiguity with respect to how times in timezones are interpreted. That is, an iCalendar contains a VTIMEZONE that has all local time transitions fully specified. That means that an iCalendar implementation must have a timezone database. [PEP 615](https://peps.python.org/pep-0615/) adds support for the IANA Time Zone Database in the base python and describes the datasources used to implement timezones and the `zoneinfo` package. However, those APIs do not expose all the underlying timezone transitions. ## Details This package works similarly to the `zoneinfo` package, exposing the underlying datasources for use by libraries that need timezone transitions. Timezone data is stored in the Time Zone Information Format (TZif) described in [rfc8536](https://datatracker.ietf.org/doc/html/rfc8536). The library will read both from the system TZ paths or from the tzdata package. TZif v3 files support a posix TZ string that describe the rules for the timezone going forward without explicit transitions. These two resources were helpful to better describe the string since it is not references in the RFC for TZif: - https://developer.ibm.com/articles/au-aix-posix/ - https://www.di-mgt.com.au/wclock/help/wclo_tzexplain.html allenporter-ical-fe8800b/ical/tzif/__init__.py000066400000000000000000000001141510550726100213600ustar00rootroot00000000000000"""Library for parsing python tzdata.""" __all__ = [ "timezoneinfo", ] allenporter-ical-fe8800b/ical/tzif/extended_timezones.py000066400000000000000000000152611510550726100235270ustar00rootroot00000000000000"""This file is automatically generated by script/update_tzlocal.py. Do not edit.""" EXTENDED_TIMEZONES = { "AUS Central Standard Time": "Australia/Darwin", "AUS Eastern Standard Time": "Australia/Sydney", "Afghanistan Standard Time": "Asia/Kabul", "Alaskan Standard Time": "America/Anchorage", "Aleutian Standard Time": "America/Adak", "Altai Standard Time": "Asia/Barnaul", "Arab Standard Time": "Asia/Riyadh", "Arabian Standard Time": "Asia/Dubai", "Arabic Standard Time": "Asia/Baghdad", "Argentina Standard Time": "America/Buenos_Aires", "Astrakhan Standard Time": "Europe/Astrakhan", "Atlantic Standard Time": "America/Halifax", "Aus Central W. Standard Time": "Australia/Eucla", "Azerbaijan Standard Time": "Asia/Baku", "Azores Standard Time": "Atlantic/Azores", "Bahia Standard Time": "America/Bahia", "Bangladesh Standard Time": "Asia/Dhaka", "Belarus Standard Time": "Europe/Minsk", "Bougainville Standard Time": "Pacific/Bougainville", "Canada Central Standard Time": "America/Regina", "Cape Verde Standard Time": "Atlantic/Cape_Verde", "Caucasus Standard Time": "Asia/Yerevan", "Cen. Australia Standard Time": "Australia/Adelaide", "Central America Standard Time": "America/Guatemala", "Central Asia Standard Time": "Asia/Almaty", "Central Brazilian Standard Time": "America/Cuiaba", "Central Europe Standard Time": "Europe/Budapest", "Central European Standard Time": "Europe/Warsaw", "Central Pacific Standard Time": "Pacific/Guadalcanal", "Central Standard Time": "America/Chicago", "Central Standard Time (Mexico)": "America/Mexico_City", "Chatham Islands Standard Time": "Pacific/Chatham", "China Standard Time": "Asia/Shanghai", "Cuba Standard Time": "America/Havana", "Dateline Standard Time": "Etc/GMT+12", "E. Africa Standard Time": "Africa/Nairobi", "E. Australia Standard Time": "Australia/Brisbane", "E. Europe Standard Time": "Europe/Chisinau", "E. South America Standard Time": "America/Sao_Paulo", "Easter Island Standard Time": "Pacific/Easter", "Eastern Standard Time": "America/New_York", "Eastern Standard Time (Mexico)": "America/Cancun", "Egypt Standard Time": "Africa/Cairo", "Ekaterinburg Standard Time": "Asia/Yekaterinburg", "FLE Standard Time": "Europe/Kiev", "Fiji Standard Time": "Pacific/Fiji", "GMT Standard Time": "Europe/London", "GTB Standard Time": "Europe/Bucharest", "Georgian Standard Time": "Asia/Tbilisi", "Greenland Standard Time": "America/Godthab", "Greenwich Standard Time": "Atlantic/Reykjavik", "Haiti Standard Time": "America/Port-au-Prince", "Hawaiian Standard Time": "Pacific/Honolulu", "India Standard Time": "Asia/Calcutta", "Iran Standard Time": "Asia/Tehran", "Israel Standard Time": "Asia/Jerusalem", "Jordan Standard Time": "Asia/Amman", "Kaliningrad Standard Time": "Europe/Kaliningrad", "Korea Standard Time": "Asia/Seoul", "Libya Standard Time": "Africa/Tripoli", "Line Islands Standard Time": "Pacific/Kiritimati", "Lord Howe Standard Time": "Australia/Lord_Howe", "Magadan Standard Time": "Asia/Magadan", "Magallanes Standard Time": "America/Punta_Arenas", "Marquesas Standard Time": "Pacific/Marquesas", "Mauritius Standard Time": "Indian/Mauritius", "Middle East Standard Time": "Asia/Beirut", "Montevideo Standard Time": "America/Montevideo", "Morocco Standard Time": "Africa/Casablanca", "Mountain Standard Time": "America/Denver", "Mountain Standard Time (Mexico)": "America/Mazatlan", "Myanmar Standard Time": "Asia/Rangoon", "N. Central Asia Standard Time": "Asia/Novosibirsk", "Namibia Standard Time": "Africa/Windhoek", "Nepal Standard Time": "Asia/Katmandu", "New Zealand Standard Time": "Pacific/Auckland", "Newfoundland Standard Time": "America/St_Johns", "Norfolk Standard Time": "Pacific/Norfolk", "North Asia East Standard Time": "Asia/Irkutsk", "North Asia Standard Time": "Asia/Krasnoyarsk", "North Korea Standard Time": "Asia/Pyongyang", "Omsk Standard Time": "Asia/Omsk", "Pacific SA Standard Time": "America/Santiago", "Pacific Standard Time": "America/Los_Angeles", "Pacific Standard Time (Mexico)": "America/Tijuana", "Pakistan Standard Time": "Asia/Karachi", "Paraguay Standard Time": "America/Asuncion", "Qyzylorda Standard Time": "Asia/Qyzylorda", "Romance Standard Time": "Europe/Paris", "Russia Time Zone 10": "Asia/Srednekolymsk", "Russia Time Zone 11": "Asia/Kamchatka", "Russia Time Zone 3": "Europe/Samara", "Russian Standard Time": "Europe/Moscow", "SA Eastern Standard Time": "America/Cayenne", "SA Pacific Standard Time": "America/Bogota", "SA Western Standard Time": "America/La_Paz", "SE Asia Standard Time": "Asia/Bangkok", "Saint Pierre Standard Time": "America/Miquelon", "Sakhalin Standard Time": "Asia/Sakhalin", "Samoa Standard Time": "Pacific/Apia", "Sao Tome Standard Time": "Africa/Sao_Tome", "Saratov Standard Time": "Europe/Saratov", "Singapore Standard Time": "Asia/Singapore", "South Africa Standard Time": "Africa/Johannesburg", "South Sudan Standard Time": "Africa/Juba", "Sri Lanka Standard Time": "Asia/Colombo", "Sudan Standard Time": "Africa/Khartoum", "Syria Standard Time": "Asia/Damascus", "Taipei Standard Time": "Asia/Taipei", "Tasmania Standard Time": "Australia/Hobart", "Tocantins Standard Time": "America/Araguaina", "Tokyo Standard Time": "Asia/Tokyo", "Tomsk Standard Time": "Asia/Tomsk", "Tonga Standard Time": "Pacific/Tongatapu", "Transbaikal Standard Time": "Asia/Chita", "Turkey Standard Time": "Europe/Istanbul", "Turks And Caicos Standard Time": "America/Grand_Turk", "US Eastern Standard Time": "America/Indianapolis", "US Mountain Standard Time": "America/Phoenix", "UTC": "Etc/UTC", "UTC+12": "Etc/GMT-12", "UTC+13": "Etc/GMT-13", "UTC-02": "Etc/GMT+2", "UTC-08": "Etc/GMT+8", "UTC-09": "Etc/GMT+9", "UTC-11": "Etc/GMT+11", "Ulaanbaatar Standard Time": "Asia/Ulaanbaatar", "Venezuela Standard Time": "America/Caracas", "Vladivostok Standard Time": "Asia/Vladivostok", "Volgograd Standard Time": "Europe/Volgograd", "W. Australia Standard Time": "Australia/Perth", "W. Central Africa Standard Time": "Africa/Lagos", "W. Europe Standard Time": "Europe/Berlin", "W. Mongolia Standard Time": "Asia/Hovd", "West Asia Standard Time": "Asia/Tashkent", "West Bank Standard Time": "Asia/Hebron", "West Pacific Standard Time": "Pacific/Port_Moresby", "Yakutsk Standard Time": "Asia/Yakutsk", "Yukon Standard Time": "America/Whitehorse", } allenporter-ical-fe8800b/ical/tzif/model.py000066400000000000000000000025451510550726100207330ustar00rootroot00000000000000"""Data model for the tzif library.""" from collections import namedtuple from dataclasses import dataclass from typing import Optional from .tz_rule import Rule @dataclass class Transition: """An individual item in the Datablock.""" transition_time: int """A transition time at which the rules for computing local time may change.""" utoff: int """Number of seconds added to UTC to determine local time.""" dst: bool """Determines if local time is Daylight Savings Time (else Standard time).""" isstdcnt: bool """Determines if the transition time is standard time (else, wall clock time).""" isutccnt: bool """Determines if the transition time is UTC time, else is a local time.""" designation: str """A designation string.""" LeapSecond = namedtuple("LeapSecond", ["occurrence", "correction"]) """A correction that needs to be applied to UTC in order to determine TAI. The occurrence is the time at which the leap-second correction occurs. The correction is the value of LEAPCORR on or after the occurrence (1 or -1). """ @dataclass class TimezoneInfo: """The results of parsing the TZif file.""" transitions: list[Transition] """Local time changes.""" leap_seconds: list[LeapSecond] rule: Optional[Rule] = None """A rule for computing local time changes after the last transition.""" allenporter-ical-fe8800b/ical/tzif/timezoneinfo.py000066400000000000000000000165711510550726100223450ustar00rootroot00000000000000"""Library for returning details about a timezone. This package follows the same approach as zoneinfo for loading timezone data. It first checks the system TZPATH, then falls back to the tzdata python package. """ from __future__ import annotations import datetime import logging import os import zoneinfo from dataclasses import dataclass from functools import cache from importlib import resources from ical.compat import timezone_compat from . import extended_timezones from .model import TimezoneInfo from .tz_rule import Rule, RuleDate from .tzif import read_tzif __all__ = [ "TimezoneInfoError", "read", ] _LOGGER = logging.getLogger(__name__) class TimezoneInfoError(Exception): """Raised on error working with timezone information.""" @cache def _read_system_timezones() -> set[str]: """Read and cache the set of system and tzdata timezones.""" return zoneinfo.available_timezones() @cache def _find_tzfile(key: str) -> str | None: """Retrieve the path to a TZif file from a key.""" for search_path in zoneinfo.TZPATH: filepath = os.path.join(search_path, key) if os.path.isfile(filepath): return filepath return None @cache def _read_tzdata_timezones() -> set[str]: """Returns the set of valid timezones from tzdata only.""" try: with ( resources.files("tzdata") .joinpath("zones") .open("r", encoding="utf-8") as zones_file ): return {line.strip() for line in zones_file.readlines()} except ModuleNotFoundError: return set() def _iana_key_to_resource(key: str) -> tuple[str, str]: """Returns the package and resource file for the specified timezone.""" if "/" not in key: return "tzdata.zoneinfo", key package_loc, resource = key.rsplit("/", 1) package = "tzdata.zoneinfo." + package_loc.replace("/", ".") return package, resource def read(key: str) -> TimezoneInfo: """Read the TZif file from the tzdata package and return timezone records.""" _LOGGER.debug("Reading timezone: %s", key) if timezone_compat.is_extended_timezones_enabled(): if target_timezone := extended_timezones.EXTENDED_TIMEZONES.get(key): _LOGGER.debug("Using extended timezone: %s", target_timezone) key = target_timezone return _read_cache(key) @cache def _read_cache(key: str) -> TimezoneInfo: if key not in _read_system_timezones() and key not in _read_tzdata_timezones(): raise TimezoneInfoError(f"Unable to find timezone in system timezones: {key}") # Prefer tzdata package (package, resource) = _iana_key_to_resource(key) try: with resources.files(package).joinpath(resource).open("rb") as tzdata_file: return read_tzif(tzdata_file.read()) except ModuleNotFoundError: # Unexpected given we previously read the list of timezones pass except ValueError as err: raise TimezoneInfoError(f"Unable to load tzdata module: {key}") from err except FileNotFoundError as err: raise TimezoneInfoError(f"Unable to load tzdata module: {key}") from err # Fallback to zoneinfo file on local disk tzfile = _find_tzfile(key) if tzfile is not None: with open(tzfile, "rb") as tzfile_file: try: return read_tzif(tzfile_file.read()) except ValueError as err: raise TimezoneInfoError(f"Unable to load tzdata file: {key}") from err raise TimezoneInfoError(f"Unable to find timezone data for {key}") _ZERO = datetime.timedelta(0) _HOUR = datetime.timedelta(hours=1) @dataclass class TzInfoResult: """Contains timezone information for a specific datetime.""" utcoffset: datetime.timedelta tzname: str | None dst: datetime.timedelta | None class TzInfo(datetime.tzinfo): """An implementation of tzinfo based on a TimezoneInfo for current TZ rules. This class is not as complete of an implementation of pythons zoneinfo rules as it currently ignores historical timezone information. Instead, it uses only the posix TZ rules that apply going forward only, but applies them for all time. This class uses the default implementation of fromutc. """ def __init__(self, rule: Rule) -> None: """Initialize TzInfo.""" self._rule: Rule = rule @classmethod def from_timezoneinfo(cls, timezoneinfo: TimezoneInfo) -> TzInfo: """Create a new instance of a TzInfo.""" if not timezoneinfo.rule: raise ValueError("Unable to make TzInfo from TimezoneInfo, missing rule") return cls(timezoneinfo.rule) def utcoffset(self, dt: datetime.datetime | None) -> datetime.timedelta: """Return offset of local time from UTC, as a timedelta object.""" if not dt: return _ZERO result = self._rule.std.offset if dst_offset := self.dst(dt): result += dst_offset return result def tzname(self, dt: datetime.datetime | None) -> str | None: """Return the time zone name for the datetime as a sorting.""" if dt is None: return None if self.dst(dt) and self._rule.dst: return self._rule.dst.name return self._rule.std.name def dst(self, dt: datetime.datetime | None) -> datetime.timedelta | None: """Return the daylight saving time (DST) adjustment, if applicable.""" if ( dt is None or not self._rule.dst or not isinstance(self._rule.dst_start, RuleDate) or not isinstance(self._rule.dst_end, RuleDate) or not self._rule.dst.offset ): return None dt_year = datetime.datetime(dt.year, 1, 1) dst_start = next(iter(self._rule.dst_start.as_rrule(dt_year))) dst_end = next(iter(self._rule.dst_end.as_rrule(dt_year))) if dst_start <= dt.replace(tzinfo=None) < dst_end: dst_offset = self._rule.dst.offset - self._rule.std.offset return dst_offset return _ZERO def __str__(self) -> str: """Return the string representation of the timezone.""" return self._rule.std.name def __repr__(self) -> str: """Return the string representation of the timezone.""" if self._rule.dst is not None: return f"TzInfo({self._rule.std.name}, {self._rule.dst.name})" return f"TzInfo({self._rule.std.name})" def read_tzinfo(key: str) -> TzInfo: """Create a zoneinfo implementation from raw tzif data.""" timezoneinfo = read(key) try: return TzInfo.from_timezoneinfo(timezoneinfo) except ValueError as err: raise TimezoneInfoError(f"Unable create TzInfo: {key}") from err @cache def _extended_timezones() -> set[str]: """Return the set of extended timezones.""" return set(extended_timezones.EXTENDED_TIMEZONES.keys()) def available_timezones() -> set[str]: """Return a set of all available timezones. This includes system timezones, tzdata timezones, and extended timezones if enabled for compatibility mode. """ result = _read_system_timezones() result |= _read_tzdata_timezones() if timezone_compat.is_extended_timezones_enabled(): result |= _extended_timezones() return result # Avoid blocking disk reads in async function by pre-loading all timezone reads for key in available_timezones(): try: read_tzinfo(key) except TimezoneInfoError: pass allenporter-ical-fe8800b/ical/tzif/tz_rule.py000066400000000000000000000175041510550726100213200ustar00rootroot00000000000000"""Library for parsing TZ rules. TZ supports these two formats No DST: std offset - std: Name of the timezone - offset: Time added to local time to get UTC Example: EST+5 DST: std offset dst [offset],start[/time],end[/time] - dst: Name of the Daylight savings time timezone - offset: Defaults to 1 hour ahead of STD offset if not specified - start & end: Time period when DST is in effect. The start/end have the following formats: Jn: A julian day between 1 and 365 (Feb 29th never counted) n: A julian day between 0 and 364 (Feb 29th is counted in leap years) Mm.w.d: m: Month between 1 and 12 d: Between 0 (Sunday) and 6 (Saturday) w: Between 1 and 5. Week 1 is first week d occurs The time field is in hh:mm:ss. The hour can be 167 to -167. """ from __future__ import annotations from dataclasses import dataclass import datetime import logging import re from typing import Any, Optional, Self, Union from dateutil import rrule from pydantic import BaseModel, field_validator, model_validator _LOGGER = logging.getLogger(__name__) _ZERO = datetime.timedelta(seconds=0) _DEFAULT_TIME_DELTA = datetime.timedelta(hours=2) def _parse_time(values: dict[str, Any]) -> datetime.timedelta | None: """Convert an offset from [+/-]hh[:mm[:ss]] to a valid timedelta pydantic format. The parse tree dict expects fields of hour, minutes, seconds (see tz_time rule in parser). """ if (hour := values["hour"]) is None: return None sign = 1 if hour.startswith("+"): hour = hour[1:] elif hour.startswith("-"): sign = -1 hour = hour[1:] minutes = values.get("minutes") or "0" seconds = values.get("seconds") or "0" return datetime.timedelta( seconds=sign * (int(hour) * 60 * 60 + int(minutes) * 60 + int(seconds)) ) @dataclass class RuleDay: """A date referenced in a timezone rule for a julian day.""" day_of_year: int """A day of the year between 1 and 365, leap days never supported.""" time: datetime.timedelta """Offset of time in current local time when the rule goes into effect, default of 02:00:00.""" @dataclass class RuleDate: """A date referenced in a timezone rule.""" month: int """A month between 1 and 12.""" day_of_week: int """A day of the week between 0 (Sunday) and 6 (Saturday).""" week_of_month: int """A week number of the month (1 to 5) based on the first occurrence of day_of_week.""" time: datetime.timedelta """Offset of time in current local time when the rule goes into effect, default of 02:00:00.""" def as_rrule(self, dtstart: datetime.datetime | None = None) -> rrule.rrule: """Return a recurrence rule for this timezone occurrence (no start date).""" dst_start_weekday = self._rrule_byday(self._rrule_week_of_month) if dtstart: dtstart = dtstart.replace(hour=0, minute=0, second=0) + self.time return rrule.rrule( freq=rrule.YEARLY, bymonth=self.month, byweekday=dst_start_weekday, dtstart=dtstart, ) @property def rrule_str(self) -> str: """Return a recurrence rule string for this timezone occurrence.""" return ";".join( [ "FREQ=YEARLY", f"BYMONTH={self.month}", f"BYDAY={self._rrule_week_of_month}{self._rrule_byday}", ] ) def rrule_dtstart(self, start: datetime.datetime) -> datetime.datetime: """Return an rrule dtstart starting at the specified date with the time applied.""" dt_start = start.replace(hour=0, minute=0, second=0) + self.time return next(iter(self.as_rrule(dt_start))) @property def _rrule_byday(self) -> rrule.weekday: """Return the dateutil weekday for this rule based on day_of_week.""" return rrule.weekdays[(self.day_of_week - 1) % 7] @property def _rrule_week_of_month(self) -> int: """Return the byday modifier for the week of the month.""" if self.week_of_month == 5: return -1 return self.week_of_month @dataclass class RuleOccurrence: """A TimeZone rule occurrence.""" name: str """The name of the timezone occurrence e.g. EST.""" offset: datetime.timedelta """UTC offset for this timezone occurrence (not time added to local time).""" def __post_init__(self) -> None: """Convert the offset from time added to local time to get UTC to a UTC offset.""" self.offset = _ZERO - self.offset @dataclass class Rule: """A rule for evaluating future timezone transitions.""" std: RuleOccurrence """An occurrence of a timezone transition for standard time.""" dst: Optional[RuleOccurrence] = None """An occurrence of a timezone transition for standard time.""" dst_start: Union[RuleDate, RuleDay, None] = None """Describes when dst goes into effect.""" dst_end: Union[RuleDate, RuleDay, None] = None """Describes when dst ends (std starts).""" def __post_init__(self) -> None: """Infer the default DST offset if not specified.""" if self.dst and not self.dst.offset: # If the dst offset is omitted, it defaults to one hour ahead of standard time. self.dst.offset = self.std.offset + datetime.timedelta(hours=1) # Regexp for parsing the TZ string _OFFSET_RE_PATTERN: re.Pattern[str] = re.compile( r"(?P(\<[+\-]?\d+\>|[a-zA-Z]+))" # name r"((?P[+-]?\d+)(?::(?P\d{1,2})(?::(?P\d{1,2}))?)?)?" # offset ) _START_END_RE_PATTERN = re.compile( # days in either julian (J prefix) or month.week.day (M prefix) format r",(J(?P\d+)|M(?P\d{1,2})\.(?P\d)\.(?P\d))" # time r"(\/(?P[+-]?\d+)(?::(?P\d{1,2})(?::(?P\d{1,2}))?)?)?" ) def _rule_occurrence_from_match(match: re.Match[str]) -> RuleOccurrence: """Create a rule occurrence from a regex match.""" return RuleOccurrence( name=match.group("name"), offset=_parse_time(match.groupdict()) or _ZERO ) def _rule_date_from_match(match: re.Match[str]) -> Union[RuleDay, RuleDate]: """Create a rule date from a regex match.""" if match["day_of_year"] is not None: return RuleDay( day_of_year=int(match.group("day_of_year")), time=_parse_time(match.groupdict()) or _DEFAULT_TIME_DELTA, ) return RuleDate( month=int(match.group("month")), week_of_month=int(match.group("week_of_month")), day_of_week=int(match.group("day_of_week")), time=_parse_time(match.groupdict()) or _DEFAULT_TIME_DELTA, ) def parse_tz_rule(tz_str: str) -> Rule: """Parse the TZ string into a Rule object.""" buffer = tz_str if (std_match := _OFFSET_RE_PATTERN.match(buffer)) is None: raise ValueError(f"Unable to parse TZ string: {tz_str}") buffer = buffer[std_match.end() :] if (dst_match := _OFFSET_RE_PATTERN.match(buffer)) is not None: buffer = buffer[dst_match.end() :] if (std_start := _START_END_RE_PATTERN.match(buffer)) is not None: buffer = buffer[std_start.end() :] if (std_end := _START_END_RE_PATTERN.match(buffer)) is not None: buffer = buffer[std_end.end() :] if (std_start is None) != (std_end is None): raise ValueError( f"Unable to parse TZ string, should have both or neither start and end dates: {tz_str}" ) if buffer: raise ValueError( f"Unable to parse TZ string, unexpected trailing data: {tz_str}" ) return Rule( std=_rule_occurrence_from_match(std_match), dst=_rule_occurrence_from_match(dst_match) if dst_match else None, dst_start=_rule_date_from_match(std_start) if std_start else None, dst_end=_rule_date_from_match(std_end) if std_end else None, ) allenporter-ical-fe8800b/ical/tzif/tzif.py000066400000000000000000000226251510550726100206100ustar00rootroot00000000000000"""Library for reading python tzdata files. An rfc5545 calendar references timezones in an unambiguous way, defined by a set of specific transitions. As a result, a calendar must have complete information about the timezones it references. Python's zoneinfo package does not expose a full representation of system supported timezones. It will use the system defined timezone, but then also may fallback to the tzdata package as a fallback. This package uses the tzdata package as a definition for timezones, under the assumption that it should be similar to existing python supported timezones. Note: This implementation is more verbose than the zoneinfo implementation and contains more documentation and references to the file format to serve as a resource for understanding the format. See rfc8536 for TZif file format. """ import enum import io import logging import struct from collections import namedtuple from collections.abc import Callable from dataclasses import dataclass from functools import cache from typing import Sequence from .model import LeapSecond, TimezoneInfo, Transition from .tz_rule import parse_tz_rule _LOGGER = logging.getLogger(__name__) # Records specifying the local time type _LOCAL_TIME_TYPE_STRUCT_FORMAT = "".join( [ ">", # Use standard size of packed value bytes "l", # utoff (4 bytes): Number of seconds to add to UTC to determine local time "?", # dst (1 byte): Indicates the time is DST (1) or standard (0) "B", # idx (1 byte): Offset index into the time zone designation octets (0-charcnt-1) ] ) _LOCAL_TIME_RECORD_SIZE = 6 class _TZifVersion(enum.Enum): """Defines information related to _TZifVersions.""" V1 = (b"\x00", 4, "l") # 32-bit in v1 V2 = (b"2", 8, "q") # 64-bit in v2+ V3 = (b"3", 8, "q") def __init__(self, version: bytes, time_size: int, time_format: str): self._version = version self._time_size = time_size self._time_format = time_format @property def version(self) -> bytes: """Return the version byte string.""" return self._version @property def time_size(self) -> int: """Return the TIME_SIZE used in the data block parsing.""" return self._time_size @property def time_format(self) -> str: """Return the struct unpack format string for TIME_SIZE objects.""" return self._time_format @dataclass class _Header: """TZif _Header information.""" SIZE = 44 # Total size of the header to read STRUCT_FORMAT = "".join( [ ">", # Use standard size of packed value bytes "4s", # magic (4 bytes) "c", # version (1 byte) "15x", # unused "6l", # isutccnt, isstdcnt, leapcnt, timecnt, typecnt, charcnt ] ) MAGIC = "TZif".encode() version: bytes """The version of the files format.""" isutccnt: int """The number of UTC/local indicators in the data block.""" isstdcnt: int """The number of standard/wall indicators in the data block.""" leapcnt: int """The number of leap second records in the data block.""" timecnt: int """The number of time transitions in the data block.""" typecnt: int """The number of local time type records in the data block.""" charcnt: int """The number of characters for time zone designations in the data block.""" @classmethod def from_bytes(cls, header_bytes: bytes) -> "_Header": """Parse the header bytes into a file.""" ( magic, version, isutccnt, isstdcnt, leapcnt, timecnt, typecnt, charcnt, ) = struct.unpack(_Header.STRUCT_FORMAT, header_bytes) if magic != _Header.MAGIC: raise ValueError("zoneinfo file did not contain magic header") if isutccnt not in (0, typecnt): raise ValueError( f"UTC/local indicators in datablock mismatched ({isutccnt}, {typecnt})" ) if isstdcnt not in (0, typecnt): raise ValueError( f"standard/wall indicators in datablock mismatched ({isutccnt}, {typecnt})" ) return _Header(version, isutccnt, isstdcnt, leapcnt, timecnt, typecnt, charcnt) _TransitionBlock = namedtuple( "_TransitionBlock", ["transition_time", "time_type", "isstdcnt", "isutccnt"] ) # A series of records specifying the local time type: # - utoff (4 bytes): Number of seconds to add to UTC to determine local time # - dst (1 byte): Indicates the time is DST (1) or standard (0) # - idx (1 byte): Offset index into the time zone designation octets (0-charcnt-1) # is the utoff (4 bytes), dst (1 byte), idx (1 byte). _LocalTimeType = namedtuple("_LocalTimeType", ["utoff", "dst", "idx"]) def _new_transition( transition: _TransitionBlock, local_time_types: list[_LocalTimeType], get_tz_designations: Callable[[int], str], ) -> Transition: """ddd.""" if transition.time_type >= len(local_time_types): raise ValueError( f"transition_type out of bounds {transition.time_type} >= {len(local_time_types)}" ) if transition.isutccnt and not transition.isstdcnt: raise ValueError("isutccnt was True but isstdcnt was False") (utoff, dst, idx) = local_time_types[transition.time_type] return Transition( transition.transition_time, utoff, dst, transition.isstdcnt, transition.isutccnt, get_tz_designations(idx), ) def _read_datablock( header: _Header, version: _TZifVersion, buf: io.BytesIO ) -> tuple[list[Transition], list[LeapSecond]]: """Read records from the buffer.""" # A series of leap-time values in sorted order transition_times = struct.unpack( f">{header.timecnt}{version.time_format}", buf.read(header.timecnt * version.time_size), ) # A series of integers specifying the type of local time of the corresponding # transition time. These are zero-based indices into the array of local # time type records. (from 0 to typecnt-1) transition_types: Sequence[int] = [] if header.timecnt > 0: transition_types = struct.unpack( f">{header.timecnt}B", buf.read(header.timecnt) ) local_time_types: list[_LocalTimeType] = [ _LocalTimeType._make( struct.unpack( _LOCAL_TIME_TYPE_STRUCT_FORMAT, buf.read(_LOCAL_TIME_RECORD_SIZE) ) ) for _ in range(header.typecnt) ] # An array of NUL-terminated time zone designation strings tz_designations = buf.read(header.charcnt) @cache def get_tz_designations(idx: int) -> str: """Find the null terminated string starting at the specified index.""" end = tz_designations.find(b"\x00", idx) return tz_designations[idx:end].decode("UTF-8") leap_seconds: list[LeapSecond] = [ LeapSecond._make( struct.unpack( f">{version.time_format}l", buf.read(version.time_size + 4), # occur + corr ) ) for _ in range(header.leapcnt) ] # Standard/wall indicators determine if the transition times are standard time (1) # or wall clock time (0). isstdcnt_types: list[bool] = [] if header.isstdcnt > 0: isstdcnt_types.extend( struct.unpack( f">{header.isstdcnt}?", buf.read(header.isstdcnt), ) ) isstdcnt_types.extend([False] * (header.timecnt - header.isstdcnt)) # UTC/local indicators determine if the transition times are UTC (1) or local time (0). isutccnt_types: list[bool] = [] if header.isutccnt > 0: isutccnt_types.extend( struct.unpack(f">{header.isutccnt}?", buf.read(header.isutccnt)) ) isutccnt_types.extend([False] * (header.timecnt - header.isutccnt)) transitions = [ _new_transition( _TransitionBlock(*values), local_time_types, get_tz_designations ) for values in zip( transition_times, transition_types, isstdcnt_types, isutccnt_types ) ] return (transitions, leap_seconds) def read_tzif(content: bytes) -> TimezoneInfo: """Read the TZif file and parse and return the timezone records.""" buf = io.BytesIO(content) # V1 header and block header = _Header.from_bytes(buf.read(_Header.SIZE)) if header.version == _TZifVersion.V1.version: if header.typecnt == 0: raise ValueError("Local time records in block is zero") if header.charcnt == 0: raise ValueError("Total number of octets is zero") (transitions, leap_seconds) = _read_datablock(header, _TZifVersion.V1, buf) if header.version == _TZifVersion.V1.version: return TimezoneInfo(transitions, leap_seconds) # V2+ header and block header = _Header.from_bytes(buf.read(_Header.SIZE)) if header.typecnt == 0: raise ValueError("Local time records in block is zero") if header.charcnt == 0: raise ValueError("Total number of octets is zero") (transitions, leap_seconds) = _read_datablock(header, _TZifVersion.V2, buf) # V2+ footer footer = buf.read() parts = footer.decode("UTF-8").split("\n") if len(parts) != 3: raise ValueError("Failed to read TZ footer") rule = None if parts[1]: rule = parse_tz_rule(parts[1]) return TimezoneInfo(transitions, leap_seconds, rule=rule) allenporter-ical-fe8800b/ical/util.py000066400000000000000000000060531510550726100176320ustar00rootroot00000000000000"""Utility methods used by multiple components.""" from __future__ import annotations from collections.abc import Sequence import datetime from importlib import metadata from types import NoneType from typing import TYPE_CHECKING, Any, Union, cast, get_args, get_origin, overload import uuid __all__ = [ "dtstamp_factory", "uid_factory", "prodid_factory", ] MIDNIGHT = datetime.time() PRODID = "github.com/allenporter/ical" VERSION = metadata.version("ical") def dtstamp_factory() -> datetime.datetime: """Factory method for new event timestamps to facilitate mocking.""" return datetime.datetime.now(tz=datetime.UTC) def uid_factory() -> str: """Factory method for new uids to facilitate mocking.""" return str(uuid.uuid1()) def prodid_factory() -> str: """Return the ical version to facilitate mocking.""" return f"-//{PRODID}//{VERSION}//EN" def local_timezone() -> datetime.tzinfo: """Get the local timezone to use when converting date to datetime.""" if local_tz := datetime.datetime.now().astimezone().tzinfo: return local_tz return datetime.timezone.utc def normalize_datetime( value: datetime.date | datetime.datetime, tzinfo: datetime.tzinfo | None = None ) -> datetime.datetime: """Convert date or datetime to a value that can be used for comparison.""" if not isinstance(value, datetime.datetime): value = datetime.datetime.combine(value, MIDNIGHT) if value.tzinfo is None: if tzinfo is None: tzinfo = local_timezone() value = value.replace(tzinfo=tzinfo) return value def get_field_type(annotation: Any) -> Any: """Filter Optional type, e.g. for 'Optional[int]' return 'int'.""" if get_origin(annotation) is Union: args: Sequence[Any] = get_args(annotation) if len(args) == 2: args = [arg for arg in args if arg is not NoneType] if len(args) == 1: return args[0] return annotation @overload def parse_date_and_datetime(value: None) -> None: ... @overload def parse_date_and_datetime(value: str | datetime.date) -> datetime.date: ... def parse_date_and_datetime(value: str | datetime.date | None) -> datetime.date | None: """Coerce str into date and datetime value.""" if not isinstance(value, str): return value if "T" in value or " " in value: return datetime.datetime.fromisoformat(value) return datetime.date.fromisoformat(value) def parse_date_and_datetime_list( values: Sequence[str] | Sequence[datetime.date], ) -> list[datetime.date | datetime.datetime]: """Coerce list[str] into list[date | datetime] values.""" if not values: return [] if not isinstance(values[0], str): if TYPE_CHECKING: values = cast(list[datetime.date | datetime.datetime], values) return values if TYPE_CHECKING: values = cast(Sequence[str], values) return [ datetime.datetime.fromisoformat(val) if "T" in val or " " in val else datetime.date.fromisoformat(val) for val in values ] allenporter-ical-fe8800b/pyproject.toml000066400000000000000000000027021510550726100203040ustar00rootroot00000000000000[build-system] build-backend = "setuptools.build_meta" requires = ["setuptools>=77.0"] [project] name = "ical" version = "12.1.0" license = "Apache-2.0" license-files = ["LICENSE"] description = "Python iCalendar implementation (rfc 2445)" readme = "README.md" authors = [{ name = "Allen Porter", email = "allen.porter@gmail.com" }] requires-python = ">=3.13" classifiers = [] dependencies = [ "python-dateutil>=2.8.2", "tzdata>=2023.3", "pydantic>=2.10.4", ] [project.urls] Source = "https://github.com/allenporter/ical" [tool.setuptools.packages.find] include = ["ical*"] [tool.mypy] plugins = "pydantic.mypy" exclude = [ "venv/", "tests/", ] platform = "linux" show_error_codes = true follow_imports = "normal" local_partial_types = true strict_equality = true no_implicit_optional = true warn_incomplete_stub = true warn_redundant_casts = true warn_unused_configs = true warn_unused_ignores = true disable_error_code = [ "import-untyped", ] extra_checks = false check_untyped_defs = true disallow_incomplete_defs = true disallow_subclassing_any = true disallow_untyped_calls = true disallow_untyped_decorators = true disallow_untyped_defs = true warn_return_any = true warn_unreachable = true # Additional checks ignore_missing_imports = true disallow_any_generics = true no_implicit_reexport = true warn_no_return = true [tool.pydantic-mypy] init_forbid_extra = false init_typed = true warn_required_dynamic_aliases = true warn_untyped_fields = true allenporter-ical-fe8800b/pytest.ini000066400000000000000000000001421510550726100174150ustar00rootroot00000000000000[pytest] log_level = DEBUG # Can be run manually with --benchmark-only addopts = --benchmark-skip allenporter-ical-fe8800b/requirements_dev.txt000066400000000000000000000007411510550726100215130ustar00rootroot00000000000000-e . coverage==7.11.3 mypy==1.18.2 pdoc==16.0.0 pip==25.3 pre-commit==4.4.0 pytest-cov==7.0.0 pytest==9.0.1 ruff==0.14.4 python-dateutil==2.9.0.post0 freezegun==1.5.5 pydantic==2.12.4 pytest-benchmark==5.2.3 types-python-dateutil==2.9.0.20251008 wheel==0.46.1 PyYAML==6.0.3 types-PyYAML==6.0.12.20250915 syrupy==5.0.0 # emoji is a dev only dependency used by script/update_emoji.py emoji==2.15.0 # tzlocal is a dev only dependency used by script/update_tzlocal.py tzlocal==5.3.1 allenporter-ical-fe8800b/script/000077500000000000000000000000001510550726100166735ustar00rootroot00000000000000allenporter-ical-fe8800b/script/run-mypy.sh000077500000000000000000000004101510550726100210250ustar00rootroot00000000000000#!/usr/bin/env bash set -o errexit # other common virtualenvs my_path=$(git rev-parse --show-toplevel) for venv in venv .venv .; do if [ -f "${my_path}/${venv}/bin/activate" ]; then . "${my_path}/${venv}/bin/activate" break fi done mypy ${my_path} allenporter-ical-fe8800b/script/update_emoji.py000066400000000000000000000013721510550726100217150ustar00rootroot00000000000000"""Script to import a more compact representation of the emoji library""" from pathlib import Path import emoji OUTPUT_FILE = Path("ical/parsing/emoji.py") EMOJI = list(emoji.EMOJI_DATA.keys()) HEADER = [ """\"\"\"This file is automatically generated by script/update_emoji.py. Do not edit.\"\"\"\n\n""" ] print("Number of emoji: {}".format(len(EMOJI))) # Write the emoji characters to a new python file as a dictionary with OUTPUT_FILE.open("w") as f: f.writelines(HEADER) f.write("EMOJI = [\n") for s in EMOJI: o = "".join([f"\\U{ord(ch):08x}" for ch in s]) f.write(f" u'{o}', # {s}\n") f.write("]\n") print("Emoji written to {}".format(OUTPUT_FILE)) print("File size: {} bytes".format(OUTPUT_FILE.stat().st_size)) allenporter-ical-fe8800b/script/update_tzlocal.py000077500000000000000000000016721510550726100222700ustar00rootroot00000000000000"""Script to import a more compact representation of the tzlocal library""" from pathlib import Path from tzlocal import windows_tz import zoneinfo OUTPUT_FILE = Path("ical/tzif/extended_timezones.py") HEADER = [ """\"\"\"This file is automatically generated by script/update_tzlocal.py. Do not edit.\"\"\"\n\n""" ] TIMEZONES = windows_tz.win_tz print("Number of timezones: {}".format(len(TIMEZONES))) available_timezones = zoneinfo.available_timezones() # Write the emoji characters to a new python file as a dictionary with OUTPUT_FILE.open("w") as f: f.writelines(HEADER) f.write("EXTENDED_TIMEZONES = {\n") for k, v in TIMEZONES.items(): if v not in available_timezones: raise ValueError(f"Timezone {k} not in available timezones") f.write(f" '{k}': '{v}',\n") f.write("}\n") print("Timezones written to {}".format(OUTPUT_FILE)) print("File size: {} bytes".format(OUTPUT_FILE.stat().st_size)) allenporter-ical-fe8800b/tests/000077500000000000000000000000001510550726100165315ustar00rootroot00000000000000allenporter-ical-fe8800b/tests/__init__.py000066400000000000000000000000261510550726100206400ustar00rootroot00000000000000"""Tests for ical.""" allenporter-ical-fe8800b/tests/__snapshots__/000077500000000000000000000000001510550726100213475ustar00rootroot00000000000000allenporter-ical-fe8800b/tests/__snapshots__/test_calendar_stream.ambr000066400000000000000000001616771510550726100264170ustar00rootroot00000000000000# serializer version: 1 # name: test_parse[datetime_local] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'dtend': '1998-01-18T23:30:00', 'dtstamp': '1998-01-15T11:05:00', 'dtstart': '1998-01-18T23:00:00', 'summary': 'Bastille Day Party', 'uid': '19970610T172345Z-AF23B2@example.com', }), ]), 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[datetime_timezone] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'dtend': '1997-07-14T14:00:00-04:00', 'dtstamp': '1997-06-10T17:23:45Z', 'dtstart': '1997-07-14T13:30:00-04:00', 'summary': 'Mid July check-in', 'uid': '19970610T172345Z-AF23B2@example.com', }), ]), 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[datetime_utc] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'dtend': '1997-07-15T04:00:00Z', 'dtstamp': '1997-06-10T17:23:45Z', 'dtstart': '1997-07-14T17:00:00Z', 'summary': 'Bastille Day Party', 'uid': '19970610T172345Z-AF23B2@example.com', }), ]), 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[datetime_vtimezone] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'created': '2023-03-13T01:12:26', 'dtend': '2023-03-12T19:12:10-07:00', 'dtstamp': '2023-03-13T01:12:26', 'dtstart': '2023-03-12T18:12:10-07:00', 'sequence': 0, 'summary': 'Event 1', 'uid': '1e19f31b-c13c-11ed-8431-066a07ffbaf5', }), ]), 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'timezones': list([ dict({ 'daylight': list([ dict({ 'dtstart': '2010-03-14T02:00:00', 'rrule': dict({ 'by_month': list([ 3, ]), 'by_weekday': list([ dict({ 'occurrence': 2, 'weekday': 'SU', }), ]), 'freq': 'YEARLY', }), 'tz_name': list([ 'PDT', ]), 'tz_offset_from': dict({ 'offset': '-PT8H', }), 'tz_offset_to': dict({ 'offset': '-PT7H', }), }), ]), 'standard': list([ dict({ 'dtstart': '2010-11-07T02:00:00', 'rrule': dict({ 'by_month': list([ 11, ]), 'by_weekday': list([ dict({ 'occurrence': 1, 'weekday': 'SU', }), ]), 'freq': 'YEARLY', }), 'tz_name': list([ 'PST', ]), 'tz_offset_from': dict({ 'offset': '-PT7H', }), 'tz_offset_to': dict({ 'offset': '-PT8H', }), }), ]), 'tz_id': 'America/Example', }), ]), 'version': '2.0', }), ]), }) # --- # name: test_parse[datetime_vtimezone_plus] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'created': '2023-03-13T01:12:26', 'dtend': '2023-03-12T19:12:10+01:00', 'dtstamp': '2023-03-13T01:12:26', 'dtstart': '2023-03-12T18:12:10+01:00', 'sequence': 0, 'summary': 'Event 1', 'uid': '1e19f31b-c13c-11ed-8431-066a07ffbaf5', }), ]), 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'timezones': list([ dict({ 'daylight': list([ dict({ 'dtstart': '2010-03-14T02:00:00', 'rrule': dict({ 'by_month': list([ 3, ]), 'by_weekday': list([ dict({ 'occurrence': 2, 'weekday': 'SU', }), ]), 'freq': 'YEARLY', }), 'tz_name': list([ 'PDT', ]), 'tz_offset_from': dict({ 'offset': 'PT2H', }), 'tz_offset_to': dict({ 'offset': 'PT1H', }), }), ]), 'standard': list([ dict({ 'dtstart': '2010-11-07T02:00:00', 'rrule': dict({ 'by_month': list([ 11, ]), 'by_weekday': list([ dict({ 'occurrence': 1, 'weekday': 'SU', }), ]), 'freq': 'YEARLY', }), 'tz_name': list([ 'PST', ]), 'tz_offset_from': dict({ 'offset': 'PT1H', }), 'tz_offset_to': dict({ 'offset': 'PT2H', }), }), ]), 'tz_id': 'Europe/Berlin', }), ]), 'version': '2.0', }), ]), }) # --- # name: test_parse[description_altrep] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'description': "The Fall'98 Wild Wizards Conference - - Las Vegas, NV, USA", 'dtend': '1997-07-15T04:00:00Z', 'dtstamp': '1997-06-10T17:23:45Z', 'dtstart': '1997-07-14T17:00:00Z', 'summary': 'Conference', 'uid': '19970610T172345Z-AF23B2@example.com', }), ]), 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[duration_negative] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'alarm': list([ dict({ 'action': 'AUDIO', 'duration': '-P13DT15H', 'extras': list([ dict({ 'name': 'attach', 'params': list([ dict({ 'name': 'FMTTYPE', 'values': list([ 'audio/basic', ]), }), ]), 'value': 'ftp://example.com/pub/sounds/bell-01.aud', }), ]), 'repeat': 4, 'trigger': '1997-03-17T13:30:00Z', }), ]), 'dtend': '2007-07-09', 'dtstamp': '2007-04-23T12:34:32Z', 'dtstart': '2007-06-28', 'summary': 'Festival International de Jazz de Montreal', 'transparency': 'TRANSPARENT', 'uid': '20070423T123432Z-541111@example.com', }), ]), 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[empty] dict({ 'calendars': list([ ]), }) # --- # name: test_parse[event_all_day] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'dtend': '2007-07-09', 'dtstamp': '2007-04-23T12:34:32Z', 'dtstart': '2007-06-28', 'summary': 'Festival International de Jazz de Montreal', 'transparency': 'TRANSPARENT', 'uid': '20070423T123432Z-541111@example.com', }), ]), 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[event_attendee] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'attendees': list([ dict({ 'member': list([ 'mailto:DEV-GROUP@example.com', ]), 'uri': 'mailto:joecool@example.com', }), dict({ 'delegator': list([ 'mailto:immud@example.com', ]), 'uri': 'mailto:ildoit@example.com', }), ]), 'dtend': '2007-07-09', 'dtstamp': '2007-04-23T12:34:32Z', 'dtstart': '2007-06-28', 'organizer': dict({ 'common_name': 'John Smith', 'uri': 'mailto:jsmith@example.com', }), 'request_status': dict({ 'exdata': 'DTSTART:96-Apr-01', 'statcode': 3.1, 'statdesc': 'Invalid property value', }), 'summary': 'Festival International de Jazz de Montreal', 'uid': '20070423T123432Z-541111@example.com', }), ]), 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[event_cal_address] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'attendees': list([ dict({ 'uri': 'mailto:ietf-calsch@example.org', 'user_type': 'GROUP', }), dict({ 'delegator': list([ 'mailto:jsmith@example.com', ]), 'uri': 'mailto:jdoe@example.com', }), dict({ 'delegate': list([ 'mailto:jdoe@example.com', 'mailto:jqpublic@example.com', ]), 'uri': 'mailto:jsmith@example.com', }), dict({ 'member': list([ 'mailto:ietf-calsch@example.org', ]), 'uri': 'mailto:jsmith@example.com', }), dict({ 'member': list([ 'mailto:projectA@example.com', 'mailto:projectB@example.com', ]), 'uri': 'mailto:janedoe@example.com', }), dict({ 'status': 'DECLINED', 'uri': 'mailto:jsmith@example.com', }), dict({ 'role': 'CHAIR', 'uri': 'mailto:mrbig@example.com', }), dict({ 'rsvp': True, 'uri': 'mailto:jsmith@example.com', }), dict({ 'sent_by': 'mailto:sray@example.com', 'uri': 'mailto:jsmith@example.com', }), ]), 'dtend': '2007-07-09', 'dtstamp': '2007-04-23T12:34:32Z', 'dtstart': '2007-06-28', 'organizer': dict({ 'directory_entry': 'ldap://example.com:6666/o=ABC%20Industries,c=US???(cn=Jim%20Dolittle)', 'uri': 'mailto:jimdo@example.com', }), 'summary': 'Festival International de Jazz de Montreal', 'uid': '20070423T123432Z-541111@example.com', }), ]), 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[event_multi_day] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'categories': list([ 'CONFERENCE', ]), 'description': ''' Networld+Interop Conference and Exhibit Atlanta World Congress Center Atlanta, Georgia ''', 'dtend': '1996-09-20T22:00:00Z', 'dtstamp': '1996-07-04T12:00:00Z', 'dtstart': '1996-09-18T14:30:00Z', 'organizer': dict({ 'uri': 'mailto:jsmith@example.com', }), 'status': 'CONFIRMED', 'summary': 'Networld+Interop Conference', 'uid': 'uid1@example.com', }), ]), 'prodid': '-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[event_priority] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'dtend': '2007-05-14T11:30:00Z', 'dtstamp': '2007-05-14T10:32:11Z', 'dtstart': '2007-05-14T11:00:00Z', 'priority': 1, 'summary': 'Client meeting', 'uid': '20070514T103211Z-123404@example.com', }), ]), 'prodid': '-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[event_properties] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'classification': 'PRIVATE', 'comment': list([ "The meeting really needs to include both ourselves and the customer. We can't hold this meeting without them. As a matter of fact, the venue for the meeting ought to be at their site. - - John", ]), 'description': ''' Meeting to provide technical review for "Phoenix" design. Happy Face Conference Room. Phoenix design team MUST attend this meeting. RSVP to team leader. ''', 'dtend': '2007-06-28T10:00:00', 'dtstamp': '2007-04-23T12:34:32Z', 'dtstart': '2007-06-28T09:00:00', 'geo': dict({ 'lat': 37.386013, 'lng': -122.082932, }), 'location': 'Conference Room - F123, Bldg. 002', 'summary': 'Customer Meeting', 'uid': '20070423T123432Z-541111@example.com', }), ]), 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[event_resources] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'dtend': '2007-06-28T10:00:00', 'dtstamp': '2007-04-23T12:34:32Z', 'dtstart': '2007-06-28T09:00:00', 'request_status': dict({ 'exdata': None, 'statcode': 2.0, 'statdesc': 'Success', }), 'resources': list([ 'EASEL', 'PROJECTOR', 'VCR', 'Nettoyeur haute pression', ]), 'summary': 'Customer Meeting', 'uid': '20070423T123432Z-541111@example.com', }), ]), 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[event_uri] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'contacts': list([ 'Jim Dolittle, ABC Industries, +1-919-555-1234', ]), 'dtend': '1998-04-10T14:17:11Z', 'dtstamp': '1998-03-13T06:00:00Z', 'dtstart': '1998-03-13T14:17:11Z', 'organizer': dict({ 'uri': 'mailto:jsmith@example.com', }), 'uid': '19970610T172345Z-AF23B2@example.com', 'url': 'http://www.example.com/calendar/busytime/jsmith.ifb', }), ]), 'prodid': '-//RDU Software//NONSGML HandCal//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[freebusy] dict({ 'calendars': list([ dict({ 'freebusy': list([ dict({ 'dtend': '1998-04-10T14:17:11Z', 'dtstamp': '1997-06-10T17:23:45Z', 'dtstart': '1998-03-13T14:17:11Z', 'freebusy': list([ dict({ 'end': '1998-03-15T00:30:00Z', 'start': '1998-03-14T23:30:00Z', }), dict({ 'end': '1998-03-16T16:30:00Z', 'start': '1998-03-16T15:30:00Z', }), dict({ 'end': '1998-03-18T04:00:00Z', 'start': '1998-03-18T03:00:00Z', }), ]), 'organizer': dict({ 'uri': 'mailto:jsmith@example.com', }), 'uid': '19970610T172345Z-AF23B2@example.com', 'url': 'http://www.example.com/calendar/busytime/jsmith.ifb', }), dict({ 'dtstamp': '1997-09-01T13:00:00Z', 'freebusy': list([ dict({ 'duration': 'PT8H30M', 'free_busy_type': 'BUSY-UNAVAILABLE', 'start': '1997-03-08T16:00:00Z', }), dict({ 'duration': 'PT3H', 'free_busy_type': 'FREE', 'start': '1997-03-08T16:00:00Z', }), dict({ 'duration': 'PT1H', 'free_busy_type': 'FREE', 'start': '1997-03-08T20:00:00Z', }), dict({ 'duration': 'PT3H', 'free_busy_type': 'FREE', 'start': '1997-03-08T16:00:00Z', }), dict({ 'duration': 'PT1H', 'free_busy_type': 'FREE', 'start': '1997-03-08T20:00:00Z', }), dict({ 'end': '1997-03-09T00:00:00Z', 'free_busy_type': 'FREE', 'start': '1997-03-08T23:00:00Z', }), ]), 'uid': '19970901T130000Z-123401@example.com', }), ]), 'prodid': '-//RDU Software//NONSGML HandCal//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[iana_property_boolean] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'dtend': '1996-09-20T22:00:00Z', 'dtstamp': '1996-07-04T12:00:00Z', 'dtstart': '1996-09-18T14:30:00Z', 'extras': list([ dict({ 'name': 'dresscode', 'params': None, 'value': 'CASUAL', }), dict({ 'name': 'non-smoking', 'params': list([ dict({ 'name': 'VALUE', 'values': list([ 'BOOLEAN', ]), }), ]), 'value': 'TRUE', }), ]), 'summary': 'Event Summary', 'uid': 'uid1@example.com', }), ]), 'prodid': '-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[journal_entry] dict({ 'calendars': list([ dict({ 'journal': list([ dict({ 'description': ''' 1. Staff meeting: Participants include Joe, Lisa, and Bob. Aurora project plans were reviewed. There is currently no budget reserves for this project. Lisa will escalate to management. Next meeting on Tuesday. 2. Telephone Conference: ABC Corp. sales representative called to discuss new printer. Promised to get us a demo by Friday. 3. Henry Miller (Handsoff Insurance): Car was totaled by tree. Is looking into a loaner car. 555-2323 (tel). ''', 'dtstamp': '1997-09-01T13:00:00Z', 'dtstart': '1997-03-17', 'summary': 'Staff meeting minutes', 'uid': '19970901T130000Z-123405@example.com', }), ]), 'prodid': '-//ABC Corporation//NONSGML My Product//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[journal_properties] dict({ 'calendars': list([ dict({ 'journal': list([ dict({ 'classification': 'PUBLIC', 'dtstamp': '1997-09-01T13:00:00Z', 'dtstart': '1997-03-17', 'status': 'FINAL', 'summary': 'Staff meeting minutes', 'uid': '19970901T130000Z-123405@example.com', }), ]), 'prodid': '-//ABC Corporation//NONSGML My Product//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[related_to] dict({ 'calendars': list([ dict({ 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'todos': list([ dict({ 'categories': list([ 'FAMILY', 'FINANCE', ]), 'classification': 'CONFIDENTIAL', 'dtstamp': '2007-03-13T12:34:32Z', 'due': '2007-05-01', 'status': 'NEEDS-ACTION', 'summary': 'Submit Quebec Income Tax Return for 2006', 'uid': '20070313T123432Z-456553@example.com', }), dict({ 'dtstamp': '2007-03-13T12:34:32Z', 'related_to': list([ dict({ 'reltype': 'PARENT', 'uid': '20070313T123432Z-456553@example.com', }), ]), 'status': 'NEEDS-ACTION', 'summary': 'Buy pens', 'uid': '20070313T123432Z-456554@example.com', }), ]), 'version': '2.0', }), ]), }) # --- # name: test_parse[related_to_default] dict({ 'calendars': list([ dict({ 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'todos': list([ dict({ 'categories': list([ 'FAMILY', 'FINANCE', ]), 'classification': 'CONFIDENTIAL', 'dtstamp': '2007-03-13T12:34:32Z', 'due': '2007-05-01', 'status': 'NEEDS-ACTION', 'summary': 'Submit Quebec Income Tax Return for 2006', 'uid': '20070313T123432Z-456553@example.com', }), dict({ 'dtstamp': '2007-03-13T12:34:32Z', 'related_to': list([ dict({ 'reltype': 'PARENT', 'uid': '20070313T123432Z-456553@example.com', }), ]), 'status': 'NEEDS-ACTION', 'summary': 'Buy pens', 'uid': '20070313T123432Z-456554@example.com', }), ]), 'version': '2.0', }), ]), }) # --- # name: test_parse[rrule-daily] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'created': '2022-07-31T19:02:07Z', 'description': '', 'dtend': '2022-08-02T09:30:00-07:00', 'dtstamp': '2022-07-31T19:04:08Z', 'dtstart': '2022-08-02T09:00:00-07:00', 'last_modified': '2022-07-31T19:02:07Z', 'location': '', 'rrule': dict({ 'freq': 'DAILY', }), 'sequence': 0, 'status': 'CONFIRMED', 'summary': 'Morning Daily Exercise', 'transparency': 'OPAQUE', 'uid': '5gog4qp8rohrj69q63ddvbnbt5@google.com', }), ]), 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[rrule-date] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'created': '2022-07-31T19:02:07Z', 'description': '', 'dtend': '2022-08-03', 'dtstamp': '2022-07-31T19:04:08Z', 'dtstart': '2022-08-02', 'last_modified': '2022-07-31T19:02:07Z', 'location': '', 'rrule': dict({ 'freq': 'DAILY', 'until': '2022-09-04', }), 'sequence': 0, 'status': 'CONFIRMED', 'summary': 'Daily Event', 'transparency': 'OPAQUE', 'uid': '5gog4qp8rohrj69q63ddvbnbt5@google.com', }), ]), 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[rrule-exdate-mismatch] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'dtend': '2023-12-24', 'dtstamp': '2023-12-10T16:34:27', 'dtstart': '2023-12-24', 'exdate': list([ '2023-12-23', '2023-12-17', ]), 'rrule': dict({ 'by_weekday': list([ dict({ 'occurrence': None, 'weekday': 'SU', }), dict({ 'occurrence': None, 'weekday': 'SA', }), ]), 'freq': 'WEEKLY', }), 'summary': 'Example date', 'uid': 'fc7a9b1e-9779-11ee-8e0d-6045bda9e0cd', }), ]), 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[rrule-exdate] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'dtend': '2022-08-02', 'dtstamp': '2022-07-31T19:04:08Z', 'dtstart': '2022-08-01', 'exdate': list([ '2022-09-01', '2022-10-01', '2023-01-01', ]), 'rrule': dict({ 'by_month_day': list([ 1, ]), 'freq': 'MONTHLY', }), 'summary': 'First of the month', 'uid': '2b83520vueebk0muv6osv1qci6@google.com', }), ]), 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[rrule-monthly] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'created': '2022-07-31T19:03:27Z', 'description': '', 'dtend': '2022-08-02', 'dtstamp': '2022-07-31T19:04:08Z', 'dtstart': '2022-08-01', 'last_modified': '2022-07-31T19:03:39Z', 'location': '', 'recurrence_id': '20220901', 'rrule': dict({ 'by_month_day': list([ 1, ]), 'freq': 'MONTHLY', }), 'sequence': 0, 'status': 'CONFIRMED', 'summary': 'First of the month', 'transparency': 'TRANSPARENT', 'uid': '2b83520vueebk0muv6osv1qci6@google.com', }), ]), 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[rrule-until-mismatch] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'created': '2022-07-31T19:02:07Z', 'description': '', 'dtend': '2022-08-03', 'dtstamp': '2022-07-31T19:04:08Z', 'dtstart': '2022-08-02', 'last_modified': '2022-07-31T19:02:07Z', 'location': '', 'rrule': dict({ 'freq': 'DAILY', 'until': '2022-09-04', }), 'sequence': 0, 'status': 'CONFIRMED', 'summary': 'Daily Event', 'transparency': 'OPAQUE', 'uid': '5gog4qp8rohrj69q63ddvbnbt5@google.com', }), ]), 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[rrule-weekly] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'created': '2022-07-31T19:02:40Z', 'description': '', 'dtend': '2022-08-05', 'dtstamp': '2022-07-31T19:04:08Z', 'dtstart': '2022-08-04', 'last_modified': '2022-07-31T19:02:51Z', 'location': '', 'rrule': dict({ 'by_weekday': list([ dict({ 'occurrence': None, 'weekday': 'TH', }), ]), 'freq': 'WEEKLY', }), 'sequence': 0, 'status': 'CONFIRMED', 'summary': 'Weekly Trash Day', 'transparency': 'TRANSPARENT', 'uid': '41bqf3it4r8kgquptq22nhj3pt@google.com', }), ]), 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[rrule-yearly] dict({ 'calendars': list([ dict({ 'events': list([ dict({ 'categories': list([ 'ANNIVERSARY', 'PERSONAL', 'SPECIAL OCCASION', ]), 'classification': 'CONFIDENTIAL', 'dtstamp': '1997-09-01T13:00:00Z', 'dtstart': '1997-11-02', 'rrule': dict({ 'freq': 'YEARLY', }), 'summary': 'Our Blissful Anniversary', 'transparency': 'TRANSPARENT', 'uid': '19970901T130000Z-123403@example.com', }), ]), 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'version': '2.0', }), ]), }) # --- # name: test_parse[timezone_ny] dict({ 'calendars': list([ dict({ 'prodid': '-//ABC Corporation//NONSGML My Product//EN', 'timezones': list([ dict({ 'daylight': list([ dict({ 'dtstart': '1967-04-30T02:00:00', 'rrule': dict({ 'by_month': list([ 4, ]), 'by_weekday': list([ dict({ 'occurrence': -1, 'weekday': 'SU', }), ]), 'freq': 'YEARLY', 'until': '1973-04-29T07:00:00Z', }), 'tz_name': list([ 'EDT', ]), 'tz_offset_from': dict({ 'offset': '-PT5H', }), 'tz_offset_to': dict({ 'offset': '-PT4H', }), }), dict({ 'dtstart': '1974-01-06T02:00:00', 'rdate': list([ '1975-02-23T02:00:00', ]), 'tz_name': list([ 'EDT', ]), 'tz_offset_from': dict({ 'offset': '-PT5H', }), 'tz_offset_to': dict({ 'offset': '-PT4H', }), }), dict({ 'dtstart': '1976-04-25T02:00:00', 'rrule': dict({ 'by_month': list([ 4, ]), 'by_weekday': list([ dict({ 'occurrence': -1, 'weekday': 'SU', }), ]), 'freq': 'YEARLY', 'until': '1986-04-27T07:00:00Z', }), 'tz_name': list([ 'EDT', ]), 'tz_offset_from': dict({ 'offset': '-PT5H', }), 'tz_offset_to': dict({ 'offset': '-PT4H', }), }), dict({ 'dtstart': '1987-04-05T02:00:00', 'rrule': dict({ 'by_month': list([ 4, ]), 'by_weekday': list([ dict({ 'occurrence': 1, 'weekday': 'SU', }), ]), 'freq': 'YEARLY', 'until': '2006-04-02T07:00:00Z', }), 'tz_name': list([ 'EDT', ]), 'tz_offset_from': dict({ 'offset': '-PT5H', }), 'tz_offset_to': dict({ 'offset': '-PT4H', }), }), dict({ 'dtstart': '2007-03-11T02:00:00', 'rrule': dict({ 'by_month': list([ 3, ]), 'by_weekday': list([ dict({ 'occurrence': 2, 'weekday': 'SU', }), ]), 'freq': 'YEARLY', }), 'tz_name': list([ 'EDT', ]), 'tz_offset_from': dict({ 'offset': '-PT5H', }), 'tz_offset_to': dict({ 'offset': '-PT4H', }), }), ]), 'last_modified': '2005-08-09T05:00:00Z', 'standard': list([ dict({ 'dtstart': '1967-10-29T02:00:00', 'rrule': dict({ 'by_month': list([ 10, ]), 'by_weekday': list([ dict({ 'occurrence': -1, 'weekday': 'SU', }), ]), 'freq': 'YEARLY', 'until': '2006-10-29T06:00:00Z', }), 'tz_name': list([ 'EST', ]), 'tz_offset_from': dict({ 'offset': '-PT4H', }), 'tz_offset_to': dict({ 'offset': '-PT5H', }), }), dict({ 'dtstart': '2007-11-04T02:00:00', 'rrule': dict({ 'by_month': list([ 11, ]), 'by_weekday': list([ dict({ 'occurrence': 1, 'weekday': 'SU', }), ]), 'freq': 'YEARLY', }), 'tz_name': list([ 'EST', ]), 'tz_offset_from': dict({ 'offset': '-PT4H', }), 'tz_offset_to': dict({ 'offset': '-PT5H', }), }), ]), 'tz_id': 'America/New_York', }), ]), 'version': '2.0', }), ]), }) # --- # name: test_parse[todo-invalid-dtstart-tzid] dict({ 'calendars': list([ dict({ 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'todos': list([ dict({ 'categories': list([ 'FAMILY', 'FINANCE', ]), 'classification': 'CONFIDENTIAL', 'dtstamp': '2007-03-13T12:34:32Z', 'due': '2007-05-01T11:00:00-07:00', 'status': 'NEEDS-ACTION', 'summary': 'Submit Quebec Income Tax Return for 2006', 'uid': '20070313T123432Z-456553@example.com', }), dict({ 'completed': '2007-07-07T10:00:00Z', 'dtstamp': '2007-05-14T10:32:11Z', 'due': '2007-07-09T13:00:00Z', 'priority': 1, 'status': 'NEEDS-ACTION', 'summary': 'Submit Revised Internet-Draft', 'uid': '20070514T103211Z-123404@example.com', }), dict({ 'categories': list([ 'FAMILY', 'FINANCE', ]), 'classification': 'CONFIDENTIAL', 'dtstamp': '2007-03-13T12:34:32Z', 'due': '2007-05-01T11:00:00', 'status': 'NEEDS-ACTION', 'summary': 'Floating due datetime', 'uid': '20070313T123432Z-456553@example.com', }), ]), 'version': '2.0', }), ]), }) # --- # name: test_parse[todo] dict({ 'calendars': list([ dict({ 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', 'todos': list([ dict({ 'categories': list([ 'FAMILY', 'FINANCE', ]), 'classification': 'CONFIDENTIAL', 'dtstamp': '2007-03-13T12:34:32Z', 'due': '2007-05-01', 'status': 'NEEDS-ACTION', 'summary': 'Submit Quebec Income Tax Return for 2006', 'uid': '20070313T123432Z-456553@example.com', }), dict({ 'completed': '2007-07-07T10:00:00Z', 'dtstamp': '2007-05-14T10:32:11Z', 'dtstart': '2007-05-14T11:00:00Z', 'due': '2007-07-09T13:00:00Z', 'priority': 1, 'status': 'NEEDS-ACTION', 'summary': 'Submit Revised Internet-Draft', 'uid': '20070514T103211Z-123404@example.com', }), ]), 'version': '2.0', }), ]), }) # --- # name: test_parse[todo_valarm] dict({ 'calendars': list([ dict({ 'prodid': '-//ABC Corporation//NONSGML My Product//EN', 'todos': list([ dict({ 'alarms': list([ dict({ 'action': 'AUDIO', 'duration': 'PT1H', 'extras': list([ dict({ 'name': 'attach', 'params': list([ dict({ 'name': 'FMTTYPE', 'values': list([ 'audio/basic', ]), }), ]), 'value': 'http://example.com/pub/audio-files/ssbanner.aud', }), ]), 'repeat': 4, 'trigger': '1998-04-03T12:00:00Z', }), ]), 'attendees': list([ dict({ 'status': 'ACCEPTED', 'uri': 'mailto:jqpublic@example.com', }), ]), 'dtstamp': '1998-01-30T13:45:00Z', 'due': '1998-04-15T00:00:00', 'organizer': dict({ 'uri': 'mailto:unclesam@example.com', }), 'sequence': 2, 'status': 'NEEDS-ACTION', 'summary': 'Submit Income Taxes', 'uid': 'uid4@example.com', }), ]), 'version': '2.0', }), ]), }) # --- # name: test_serialize[datetime_local] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:19980115T110500 UID:19970610T172345Z-AF23B2@example.com DTSTART:19980118T230000 DTEND:19980118T233000 SUMMARY:Bastille Day Party END:VEVENT END:VCALENDAR ''' # --- # name: test_serialize[datetime_timezone] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:19970610T172345Z UID:19970610T172345Z-AF23B2@example.com DTSTART;TZID=America/New_York:19970714T133000 DTEND;TZID=America/New_York:19970714T140000 SUMMARY:Mid July check-in END:VEVENT END:VCALENDAR ''' # --- # name: test_serialize[datetime_utc] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:19970610T172345Z UID:19970610T172345Z-AF23B2@example.com DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:Bastille Day Party END:VEVENT END:VCALENDAR ''' # --- # name: test_serialize[datetime_vtimezone] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20230313T011226 UID:1e19f31b-c13c-11ed-8431-066a07ffbaf5 DTSTART;TZID=America/Example:20230312T181210 DTEND;TZID=America/Example:20230312T191210 SUMMARY:Event 1 CREATED:20230313T011226 SEQUENCE:0 END:VEVENT BEGIN:VTIMEZONE TZID:America/Example BEGIN:STANDARD DTSTART:20101107T020000 TZOFFSETTO:-0800 TZOFFSETFROM:-0700 RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11 TZNAME:PST END:STANDARD BEGIN:DAYLIGHT DTSTART:20100314T020000 TZOFFSETTO:-0700 TZOFFSETFROM:-0800 RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3 TZNAME:PDT END:DAYLIGHT END:VTIMEZONE END:VCALENDAR ''' # --- # name: test_serialize[datetime_vtimezone_plus] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20230313T011226 UID:1e19f31b-c13c-11ed-8431-066a07ffbaf5 DTSTART;TZID=Europe/Berlin:20230312T181210 DTEND;TZID=Europe/Berlin:20230312T191210 SUMMARY:Event 1 CREATED:20230313T011226 SEQUENCE:0 END:VEVENT BEGIN:VTIMEZONE TZID:Europe/Berlin BEGIN:STANDARD DTSTART:20101107T020000 TZOFFSETTO:+0200 TZOFFSETFROM:+0100 RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11 TZNAME:PST END:STANDARD BEGIN:DAYLIGHT DTSTART:20100314T020000 TZOFFSETTO:+0100 TZOFFSETFROM:+0200 RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3 TZNAME:PDT END:DAYLIGHT END:VTIMEZONE END:VCALENDAR ''' # --- # name: test_serialize[description_altrep] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:19970610T172345Z UID:19970610T172345Z-AF23B2@example.com DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:Conference DESCRIPTION:The Fall'98 Wild Wizards Conference - - Las Vegas\, NV\, USA END:VEVENT END:VCALENDAR ''' # --- # name: test_serialize[duration_negative] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20070423T123432Z UID:20070423T123432Z-541111@example.com DTSTART:20070628 DTEND:20070709 SUMMARY:Festival International de Jazz de Montreal TRANSP:TRANSPARENT BEGIN:VALARM ACTION:AUDIO TRIGGER:19970317T133000Z DURATION:-P1W6DT15H REPEAT:4 END:VALARM END:VEVENT END:VCALENDAR ''' # --- # name: test_serialize[empty] '' # --- # name: test_serialize[event_all_day] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20070423T123432Z UID:20070423T123432Z-541111@example.com DTSTART:20070628 DTEND:20070709 SUMMARY:Festival International de Jazz de Montreal TRANSP:TRANSPARENT END:VEVENT END:VCALENDAR ''' # --- # name: test_serialize[event_attendee] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20070423T123432Z UID:20070423T123432Z-541111@example.com DTSTART:20070628 DTEND:20070709 SUMMARY:Festival International de Jazz de Montreal ATTENDEE;MEMBER="mailto:DEV-GROUP@example.com":mailto:joecool@example.com ATTENDEE;DELEGATED-FROM="mailto:immud@example.com":mailto:ildoit@example.co m ORGANIZER;CN=John Smith:mailto:jsmith@example.com REQUEST-STATUS:3.1;Invalid property value;DTSTART:96-Apr-01 END:VEVENT END:VCALENDAR ''' # --- # name: test_serialize[event_cal_address] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20070423T123432Z UID:20070423T123432Z-541111@example.com DTSTART:20070628 DTEND:20070709 SUMMARY:Festival International de Jazz de Montreal ATTENDEE;CUTYPE=GROUP:mailto:ietf-calsch@example.org ATTENDEE;DELEGATED-FROM="mailto:jsmith@example.com":mailto:jdoe@example.com ATTENDEE;DELEGATED-TO="mailto:jdoe@example.com","mailto:jqpublic@example.co m":mailto:jsmith@example.com ATTENDEE;MEMBER="mailto:ietf-calsch@example.org":mailto:jsmith@example.com ATTENDEE;MEMBER="mailto:projectA@example.com","mailto:projectB@example.com" :mailto:janedoe@example.com ATTENDEE;PARTSTAT=DECLINED:mailto:jsmith@example.com ATTENDEE;ROLE=CHAIR:mailto:mrbig@example.com ATTENDEE;RSVP=TRUE:mailto:jsmith@example.com ATTENDEE;SENT-BY="mailto:sray@example.com":mailto:jsmith@example.com ORGANIZER;DIR="ldap://example.com:6666/o=ABC%20Industries,c=US???(cn=Jim%20 Dolittle)":mailto:jimdo@example.com END:VEVENT END:VCALENDAR ''' # --- # name: test_serialize[event_multi_day] ''' BEGIN:VCALENDAR PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:19960704T120000Z UID:uid1@example.com DTSTART:19960918T143000Z DTEND:19960920T220000Z SUMMARY:Networld+Interop Conference CATEGORIES:CONFERENCE DESCRIPTION:Networld+Interop Conference and Exhibit\nAtlanta World Congress Center\nAtlanta\, Georgia ORGANIZER:mailto:jsmith@example.com STATUS:CONFIRMED END:VEVENT END:VCALENDAR ''' # --- # name: test_serialize[event_priority] ''' BEGIN:VCALENDAR PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20070514T103211Z UID:20070514T103211Z-123404@example.com DTSTART:20070514T110000Z DTEND:20070514T113000Z SUMMARY:Client meeting PRIORITY:1 END:VEVENT END:VCALENDAR ''' # --- # name: test_serialize[event_properties] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20070423T123432Z UID:20070423T123432Z-541111@example.com DTSTART:20070628T090000 DTEND:20070628T100000 SUMMARY:Customer Meeting CLASS:PRIVATE COMMENT:The meeting really needs to include both ourselves and the customer. We can't hold this meeting without them. As a matter of fact\, the venue for the meeting ought to be at their site. - - John DESCRIPTION:Meeting to provide technical review for "Phoenix" design.\nHappy Face Conference Room. Phoenix design team MUST attend this meeting.\nRSVP to team leader. GEO:37.386013;-122.082932 LOCATION:Conference Room - F123\, Bldg. 002 END:VEVENT END:VCALENDAR ''' # --- # name: test_serialize[event_resources] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20070423T123432Z UID:20070423T123432Z-541111@example.com DTSTART:20070628T090000 DTEND:20070628T100000 SUMMARY:Customer Meeting RESOURCES:EASEL RESOURCES:PROJECTOR RESOURCES:VCR RESOURCES:Nettoyeur haute pression REQUEST-STATUS:2.0;Success END:VEVENT END:VCALENDAR ''' # --- # name: test_serialize[event_uri] ''' BEGIN:VCALENDAR PRODID:-//RDU Software//NONSGML HandCal//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:19980313T060000Z UID:19970610T172345Z-AF23B2@example.com DTSTART:19980313T141711Z DTEND:19980410T141711Z CONTACT:Jim Dolittle\, ABC Industries\, +1-919-555-1234 ORGANIZER:mailto:jsmith@example.com URL:http://www.example.com/calendar/busytime/jsmith.ifb END:VEVENT END:VCALENDAR ''' # --- # name: test_serialize[freebusy] ''' BEGIN:VCALENDAR PRODID:-//RDU Software//NONSGML HandCal//EN VERSION:2.0 BEGIN:VFREEBUSY DTSTAMP:19970610T172345Z UID:19970610T172345Z-AF23B2@example.com DTSTART:19980313T141711Z DTEND:19980410T141711Z FREEBUSY:19980314T233000Z/19980315T003000Z FREEBUSY:19980316T153000Z/19980316T163000Z FREEBUSY:19980318T030000Z/19980318T040000Z ORGANIZER:mailto:jsmith@example.com URL:http://www.example.com/calendar/busytime/jsmith.ifb END:VFREEBUSY BEGIN:VFREEBUSY DTSTAMP:19970901T130000Z UID:19970901T130000Z-123401@example.com FREEBUSY;FBTYPE=BUSY-UNAVAILABLE:19970308T160000Z/PT8H30M FREEBUSY;FBTYPE=FREE:19970308T160000Z/PT3H FREEBUSY;FBTYPE=FREE:19970308T200000Z/PT1H FREEBUSY;FBTYPE=FREE:19970308T160000Z/PT3H FREEBUSY;FBTYPE=FREE:19970308T200000Z/PT1H FREEBUSY;FBTYPE=FREE:19970308T230000Z/19970309T000000Z END:VFREEBUSY END:VCALENDAR ''' # --- # name: test_serialize[iana_property_boolean] ''' BEGIN:VCALENDAR PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:19960704T120000Z UID:uid1@example.com DTSTART:19960918T143000Z DTEND:19960920T220000Z SUMMARY:Event Summary END:VEVENT END:VCALENDAR ''' # --- # name: test_serialize[journal_entry] ''' BEGIN:VCALENDAR PRODID:-//ABC Corporation//NONSGML My Product//EN VERSION:2.0 BEGIN:VJOURNAL DTSTAMP:19970901T130000Z UID:19970901T130000Z-123405@example.com DESCRIPTION:1. Staff meeting: Participants include Joe\, Lisa\, and Bob. Aurora project plans were reviewed. There is currently no budget reserves for this project. Lisa will escalate to management. Next meeting on Tuesday.\n2. Telephone Conference: ABC Corp. sales representative called to discuss new printer. Promised to get us a demo by Friday.\n3. Henry Miller (Handsoff Insurance): Car was totaled by tree. Is looking into a loaner car. 555-2323 (tel). DTSTART:19970317 SUMMARY:Staff meeting minutes END:VJOURNAL END:VCALENDAR ''' # --- # name: test_serialize[journal_properties] ''' BEGIN:VCALENDAR PRODID:-//ABC Corporation//NONSGML My Product//EN VERSION:2.0 BEGIN:VJOURNAL DTSTAMP:19970901T130000Z UID:19970901T130000Z-123405@example.com CLASS:PUBLIC DTSTART:19970317 STATUS:FINAL SUMMARY:Staff meeting minutes END:VJOURNAL END:VCALENDAR ''' # --- # name: test_serialize[related_to] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VTODO DTSTAMP:20070313T123432Z UID:20070313T123432Z-456553@example.com CATEGORIES:FAMILY CATEGORIES:FINANCE CLASS:CONFIDENTIAL DUE:20070501 STATUS:NEEDS-ACTION SUMMARY:Submit Quebec Income Tax Return for 2006 END:VTODO BEGIN:VTODO DTSTAMP:20070313T123432Z UID:20070313T123432Z-456554@example.com RELATED-TO;RELTYPE=PARENT:20070313T123432Z-456553@example.com STATUS:NEEDS-ACTION SUMMARY:Buy pens END:VTODO END:VCALENDAR ''' # --- # name: test_serialize[related_to_default] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VTODO DTSTAMP:20070313T123432Z UID:20070313T123432Z-456553@example.com CATEGORIES:FAMILY CATEGORIES:FINANCE CLASS:CONFIDENTIAL DUE:20070501 STATUS:NEEDS-ACTION SUMMARY:Submit Quebec Income Tax Return for 2006 END:VTODO BEGIN:VTODO DTSTAMP:20070313T123432Z UID:20070313T123432Z-456554@example.com RELATED-TO;RELTYPE=PARENT:20070313T123432Z-456553@example.com STATUS:NEEDS-ACTION SUMMARY:Buy pens END:VTODO END:VCALENDAR ''' # --- # name: test_serialize[rrule-daily] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20220731T190408Z UID:5gog4qp8rohrj69q63ddvbnbt5@google.com DTSTART;TZID=America/Los_Angeles:20220802T090000 DTEND;TZID=America/Los_Angeles:20220802T093000 SUMMARY:Morning Daily Exercise CREATED:20220731T190207Z DESCRIPTION: LAST-MODIFIED:20220731T190207Z LOCATION: RRULE:FREQ=DAILY SEQUENCE:0 STATUS:CONFIRMED TRANSP:OPAQUE END:VEVENT END:VCALENDAR ''' # --- # name: test_serialize[rrule-date] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20220731T190408Z UID:5gog4qp8rohrj69q63ddvbnbt5@google.com DTSTART:20220802 DTEND:20220803 SUMMARY:Daily Event CREATED:20220731T190207Z DESCRIPTION: LAST-MODIFIED:20220731T190207Z LOCATION: RRULE:FREQ=DAILY;UNTIL=20220904 SEQUENCE:0 STATUS:CONFIRMED TRANSP:OPAQUE END:VEVENT END:VCALENDAR ''' # --- # name: test_serialize[rrule-exdate-mismatch] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20231210T163427 UID:fc7a9b1e-9779-11ee-8e0d-6045bda9e0cd DTSTART:20231224 DTEND:20231224 SUMMARY:Example date RRULE:FREQ=WEEKLY;BYDAY=SU,SA EXDATE:20231223 EXDATE:20231217 END:VEVENT END:VCALENDAR ''' # --- # name: test_serialize[rrule-exdate] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20220731T190408Z UID:2b83520vueebk0muv6osv1qci6@google.com DTSTART:20220801 DTEND:20220802 SUMMARY:First of the month RRULE:FREQ=MONTHLY;BYMONTHDAY=1 EXDATE:20220901 EXDATE:20221001 EXDATE:20230101 END:VEVENT END:VCALENDAR ''' # --- # name: test_serialize[rrule-monthly] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20220731T190408Z UID:2b83520vueebk0muv6osv1qci6@google.com DTSTART:20220801 DTEND:20220802 SUMMARY:First of the month CREATED:20220731T190327Z DESCRIPTION: LAST-MODIFIED:20220731T190339Z LOCATION: RECURRENCE-ID:20220901 RRULE:FREQ=MONTHLY;BYMONTHDAY=1 SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT END:VCALENDAR ''' # --- # name: test_serialize[rrule-until-mismatch] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20220731T190408Z UID:5gog4qp8rohrj69q63ddvbnbt5@google.com DTSTART:20220802 DTEND:20220803 SUMMARY:Daily Event CREATED:20220731T190207Z DESCRIPTION: LAST-MODIFIED:20220731T190207Z LOCATION: RRULE:FREQ=DAILY;UNTIL=20220904 SEQUENCE:0 STATUS:CONFIRMED TRANSP:OPAQUE END:VEVENT END:VCALENDAR ''' # --- # name: test_serialize[rrule-weekly] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20220731T190408Z UID:41bqf3it4r8kgquptq22nhj3pt@google.com DTSTART:20220804 DTEND:20220805 SUMMARY:Weekly Trash Day CREATED:20220731T190240Z DESCRIPTION: LAST-MODIFIED:20220731T190251Z LOCATION: RRULE:FREQ=WEEKLY;BYDAY=TH SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT END:VCALENDAR ''' # --- # name: test_serialize[rrule-yearly] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:19970901T130000Z UID:19970901T130000Z-123403@example.com DTSTART:19971102 SUMMARY:Our Blissful Anniversary CATEGORIES:ANNIVERSARY CATEGORIES:PERSONAL CATEGORIES:SPECIAL OCCASION CLASS:CONFIDENTIAL RRULE:FREQ=YEARLY TRANSP:TRANSPARENT END:VEVENT END:VCALENDAR ''' # --- # name: test_serialize[timezone_ny] ''' BEGIN:VCALENDAR PRODID:-//ABC Corporation//NONSGML My Product//EN VERSION:2.0 BEGIN:VTIMEZONE TZID:America/New_York LAST-MODIFIED:20050809T050000Z BEGIN:STANDARD DTSTART:19671029T020000 TZOFFSETTO:-0500 TZOFFSETFROM:-0400 RRULE:FREQ=YEARLY;UNTIL=20061029T060000Z;BYDAY=-1SU;BYMONTH=10 TZNAME:EST END:STANDARD BEGIN:STANDARD DTSTART:20071104T020000 TZOFFSETTO:-0500 TZOFFSETFROM:-0400 RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11 TZNAME:EST END:STANDARD BEGIN:DAYLIGHT DTSTART:19670430T020000 TZOFFSETTO:-0400 TZOFFSETFROM:-0500 RRULE:FREQ=YEARLY;UNTIL=19730429T070000Z;BYDAY=-1SU;BYMONTH=4 TZNAME:EDT END:DAYLIGHT BEGIN:DAYLIGHT DTSTART:19740106T020000 TZOFFSETTO:-0400 TZOFFSETFROM:-0500 RDATE:19750223T020000 TZNAME:EDT END:DAYLIGHT BEGIN:DAYLIGHT DTSTART:19760425T020000 TZOFFSETTO:-0400 TZOFFSETFROM:-0500 RRULE:FREQ=YEARLY;UNTIL=19860427T070000Z;BYDAY=-1SU;BYMONTH=4 TZNAME:EDT END:DAYLIGHT BEGIN:DAYLIGHT DTSTART:19870405T020000 TZOFFSETTO:-0400 TZOFFSETFROM:-0500 RRULE:FREQ=YEARLY;UNTIL=20060402T070000Z;BYDAY=1SU;BYMONTH=4 TZNAME:EDT END:DAYLIGHT BEGIN:DAYLIGHT DTSTART:20070311T020000 TZOFFSETTO:-0400 TZOFFSETFROM:-0500 RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3 TZNAME:EDT END:DAYLIGHT END:VTIMEZONE END:VCALENDAR ''' # --- # name: test_serialize[todo-invalid-dtstart-tzid] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VTODO DTSTAMP:20070313T123432Z UID:20070313T123432Z-456553@example.com CATEGORIES:FAMILY CATEGORIES:FINANCE CLASS:CONFIDENTIAL DUE;TZID=America/Los_Angeles:20070501T110000 STATUS:NEEDS-ACTION SUMMARY:Submit Quebec Income Tax Return for 2006 END:VTODO BEGIN:VTODO DTSTAMP:20070514T103211Z UID:20070514T103211Z-123404@example.com COMPLETED:20070707T100000Z DUE:20070709T130000Z PRIORITY:1 STATUS:NEEDS-ACTION SUMMARY:Submit Revised Internet-Draft END:VTODO BEGIN:VTODO DTSTAMP:20070313T123432Z UID:20070313T123432Z-456553@example.com CATEGORIES:FAMILY CATEGORIES:FINANCE CLASS:CONFIDENTIAL DUE:20070501T110000 STATUS:NEEDS-ACTION SUMMARY:Floating due datetime END:VTODO END:VCALENDAR ''' # --- # name: test_serialize[todo] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VTODO DTSTAMP:20070313T123432Z UID:20070313T123432Z-456553@example.com CATEGORIES:FAMILY CATEGORIES:FINANCE CLASS:CONFIDENTIAL DUE:20070501 STATUS:NEEDS-ACTION SUMMARY:Submit Quebec Income Tax Return for 2006 END:VTODO BEGIN:VTODO DTSTAMP:20070514T103211Z UID:20070514T103211Z-123404@example.com COMPLETED:20070707T100000Z DTSTART:20070514T110000Z DUE:20070709T130000Z PRIORITY:1 STATUS:NEEDS-ACTION SUMMARY:Submit Revised Internet-Draft END:VTODO END:VCALENDAR ''' # --- # name: test_serialize[todo_valarm] ''' BEGIN:VCALENDAR PRODID:-//ABC Corporation//NONSGML My Product//EN VERSION:2.0 BEGIN:VTODO DTSTAMP:19980130T134500Z UID:uid4@example.com ATTENDEE;PARTSTAT=ACCEPTED:mailto:jqpublic@example.com DUE:19980415T000000 ORGANIZER:mailto:unclesam@example.com SEQUENCE:2 STATUS:NEEDS-ACTION SUMMARY:Submit Income Taxes BEGIN:VALARM ACTION:AUDIO TRIGGER:19980403T120000Z DURATION:PT1H REPEAT:4 END:VALARM END:VTODO END:VCALENDAR ''' # --- allenporter-ical-fe8800b/tests/__snapshots__/test_diagnostics.ambr000066400000000000000000000012511510550726100255570ustar00rootroot00000000000000# serializer version: 1 # name: test_redact_date_timezone[datetime_timezone] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:*** BEGIN:VEVENT DTSTAMP:19970610T172345Z UID:*** DTSTART;TZID=America/New_York:19970714T133000 DTEND;TZID=America/New_York:19970714T140000 SUMMARY:*** END:VEVENT END:VCALENDAR ''' # --- # name: test_redact_date_timezone[description_altrep] ''' BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:*** BEGIN:VEVENT DTSTAMP:19970610T172345Z UID:*** DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:*** DESCRIPTION:*** *** END:VEVENT END:VCALENDAR ''' # --- allenporter-ical-fe8800b/tests/__snapshots__/test_store.ambr000066400000000000000000000745541510550726100244240ustar00rootroot00000000000000# serializer version: 1 # name: test_add_and_delete_event list([ dict({ 'created': '2022-09-03T09:38:05', 'dtend': '2022-08-29T09:30:00', 'dtstamp': '2022-09-03T09:38:05', 'dtstart': '2022-08-29T09:00:00', 'sequence': 0, 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_add_and_delete_todo list([ dict({ 'created': '2022-09-03T09:38:05+00:00', 'dtstamp': '2022-09-03T09:38:05+00:00', 'dtstart': '2022-08-28T09:00:00', 'due': '2022-08-29T09:00:00', 'sequence': 0, 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_add_todo_with_status[completed] list([ dict({ 'completed': '2022-09-03T09:38:05+00:00', 'created': '2022-09-03T09:38:05+00:00', 'dtstamp': '2022-09-03T09:38:05+00:00', 'dtstart': '2022-08-28T09:00:00', 'due': '2022-08-29T09:00:00', 'sequence': 0, 'status': , 'summary': 'Do chores', 'uid': 'mock-uid-1', }), ]) # --- # name: test_add_todo_with_status[completed_with_timestamp] list([ dict({ 'completed': '2020-01-01T00:00:00+00:00', 'created': '2022-09-03T09:38:05+00:00', 'dtstamp': '2022-09-03T09:38:05+00:00', 'dtstart': '2022-08-28T09:00:00', 'due': '2022-08-29T09:00:00', 'sequence': 0, 'status': , 'summary': 'Do chores', 'uid': 'mock-uid-1', }), ]) # --- # name: test_add_todo_with_status[needs_action] list([ dict({ 'created': '2022-09-03T09:38:05+00:00', 'dtstamp': '2022-09-03T09:38:05+00:00', 'dtstart': '2022-08-28T09:00:00', 'due': '2022-08-29T09:00:00', 'sequence': 0, 'status': , 'summary': 'Do chores', 'uid': 'mock-uid-1', }), ]) # --- # name: test_convert_single_instance_to_recurring list([ dict({ 'dtstart': '2022-08-29T09:00:00', 'summary': 'Daily meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_convert_single_instance_to_recurring.1 list([ dict({ 'dtstart': '2022-08-29T09:00:00', 'recurrence_id': '20220829T090000', 'summary': 'Daily meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-08-30T09:00:00', 'recurrence_id': '20220830T090000', 'summary': 'Daily meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-08-31T09:00:00', 'recurrence_id': '20220831T090000', 'summary': 'Daily meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_delete_all_day_event list([ dict({ 'created': '2022-09-03T09:38:05', 'dtend': '2022-08-29', 'dtstamp': '2022-09-03T09:38:05', 'dtstart': '2022-08-29', 'sequence': 0, 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_delete_all_day_recurring list([ dict({ 'dtstart': '2022-08-29', 'recurrence_id': '20220829', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-05', 'recurrence_id': '20220905', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-12', 'recurrence_id': '20220912', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_delete_all_day_recurring.1 list([ dict({ 'dtstart': '2022-08-29', 'recurrence_id': '20220829', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-12', 'recurrence_id': '20220912', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_delete_event_parent_cascade_to_children list([ 'mock-uid-1', 'mock-uid-2', 'mock-uid-3', 'mock-uid-4', ]) # --- # name: test_delete_event_parent_cascade_to_children.1 list([ 'mock-uid-4', ]) # --- # name: test_delete_instance_in_todo_series list([ tuple( '2024-01-09', None, Recur(freq=, until=None, count=10, interval=1, by_weekday=[], by_month_day=[], by_month=[], by_setpos=[]), ), ]) # --- # name: test_delete_instance_in_todo_series.1 list([ tuple( '2024-01-09', '20240109', None, list([ ]), ), tuple( '2024-01-09', None, Recur(freq=, until=None, count=10, interval=1, by_weekday=[], by_month_day=[], by_month=[], by_setpos=[]), list([ FakeDate(2024, 1, 9), ]), ), ]) # --- # name: test_delete_instance_in_todo_series.2 list([ tuple( '2024-01-09', '20240109', None, list([ ]), ), tuple( '2024-01-09', None, Recur(freq=, until=None, count=10, interval=1, by_weekday=[], by_month_day=[], by_month=[], by_setpos=[]), list([ FakeDate(2024, 1, 9), FakeDate(2024, 1, 10), ]), ), ]) # --- # name: test_delete_instance_in_todo_series.3 list([ dict({ 'due': '2024-01-10', 'recurrence_id': '20240109', 'status': , 'summary': 'Walk dog', 'uid': 'mock-uid-1', }), ]) # --- # name: test_delete_instance_in_todo_series.4 list([ dict({ 'due': '2024-01-12', 'recurrence_id': '20240111', 'status': , 'summary': 'Walk dog', 'uid': 'mock-uid-1', }), ]) # --- # name: test_delete_instance_in_todo_series.5 list([ dict({ 'due': '2024-01-13', 'recurrence_id': '20240112', 'status': , 'summary': 'Walk dog', 'uid': 'mock-uid-1', }), ]) # --- # name: test_delete_parent_todo_cascade_to_children list([ 'mock-uid-1', 'mock-uid-2', 'mock-uid-3', 'mock-uid-4', ]) # --- # name: test_delete_parent_todo_cascade_to_children.1 list([ 'mock-uid-4', ]) # --- # name: test_delete_this_and_future_all_day_event[recur0] list([ dict({ 'dtstart': '2022-08-29', 'recurrence_id': '20220829', 'summary': 'Mondays', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-05', 'recurrence_id': '20220905', 'summary': 'Mondays', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-12', 'recurrence_id': '20220912', 'summary': 'Mondays', 'uid': 'mock-uid-1', }), ]) # --- # name: test_delete_this_and_future_all_day_event[recur1] list([ dict({ 'dtstart': '2022-08-29', 'recurrence_id': '20220829', 'summary': 'Mondays', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-05', 'recurrence_id': '20220905', 'summary': 'Mondays', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-12', 'recurrence_id': '20220912', 'summary': 'Mondays', 'uid': 'mock-uid-1', }), ]) # --- # name: test_delete_this_and_future_event[recur0] list([ dict({ 'dtstart': '2022-08-29T09:00:00', 'recurrence_id': '20220829T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-05T09:00:00', 'recurrence_id': '20220905T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-12T09:00:00', 'recurrence_id': '20220912T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_delete_this_and_future_event[recur1] list([ dict({ 'dtstart': '2022-08-29T09:00:00', 'recurrence_id': '20220829T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-05T09:00:00', 'recurrence_id': '20220905T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-12T09:00:00', 'recurrence_id': '20220912T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_deletel_partial_recurring_event[recur0] list([ dict({ 'dtstart': '2022-08-29T09:00:00', 'recurrence_id': '20220829T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-12T09:00:00', 'recurrence_id': '20220912T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-26T09:00:00', 'recurrence_id': '20220926T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_deletel_partial_recurring_event[recur1] list([ dict({ 'dtstart': '2022-08-29T09:00:00', 'recurrence_id': '20220829T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-12T09:00:00', 'recurrence_id': '20220912T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-26T09:00:00', 'recurrence_id': '20220926T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_edit_event list([ dict({ 'created': '2022-09-03T09:38:05', 'dtend': '2022-08-29T09:30:00', 'dtstamp': '2022-09-03T09:38:05', 'dtstart': '2022-08-29T09:00:00', 'sequence': 0, 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_edit_event.1 list([ dict({ 'created': '2022-09-03T09:38:05', 'dtend': '2022-08-29T09:30:00', 'dtstamp': '2022-09-03T09:38:15', 'dtstart': '2022-08-29T09:05:00', 'last_modified': '2022-09-03T09:38:15', 'sequence': 1, 'summary': 'Monday meeting (Delayed)', 'uid': 'mock-uid-1', }), ]) # --- # name: test_edit_recurrence_rule_this_and_future list([ dict({ 'dtstart': '2022-08-29T09:00:00', 'recurrence_id': '20220829T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-05T09:00:00', 'recurrence_id': '20220905T090000', 'summary': 'Team meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-19T09:00:00', 'recurrence_id': '20220919T090000', 'summary': 'Team meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_edit_recurrence_rule_this_and_future_all_day_first_instance list([ dict({ 'dtstart': '2022-08-29', 'recurrence_id': '20220829', 'summary': 'Mondays [edit]', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-12', 'recurrence_id': '20220912', 'summary': 'Mondays [edit]', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-26', 'recurrence_id': '20220926', 'summary': 'Mondays [edit]', 'uid': 'mock-uid-1', }), ]) # --- # name: test_edit_recurrence_rule_this_and_future_first_instance list([ dict({ 'dtstart': '2022-08-29T09:00:00', 'recurrence_id': '20220829T090000', 'summary': 'Team meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-12T09:00:00', 'recurrence_id': '20220912T090000', 'summary': 'Team meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-26T09:00:00', 'recurrence_id': '20220926T090000', 'summary': 'Team meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_edit_recurring_all_day_event_instance[recur0] list([ dict({ 'dtstart': '2022-08-29', 'recurrence_id': '20220829', 'sequence': 0, 'summary': 'Monday event', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-06', 'recurrence_id': '20220905', 'sequence': 1, 'summary': 'Tuesday event', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-12', 'recurrence_id': '20220912', 'sequence': 0, 'summary': 'Monday event', 'uid': 'mock-uid-1', }), ]) # --- # name: test_edit_recurring_all_day_event_instance[recur1] list([ dict({ 'dtstart': '2022-08-29', 'recurrence_id': '20220829', 'sequence': 0, 'summary': 'Monday event', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-06', 'recurrence_id': '20220905', 'sequence': 1, 'summary': 'Tuesday event', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-12', 'recurrence_id': '20220912', 'sequence': 0, 'summary': 'Monday event', 'uid': 'mock-uid-1', }), ]) # --- # name: test_edit_recurring_all_day_event_this_and_future[recur0] list([ dict({ 'dtstart': '2022-08-29', 'recurrence_id': '20220829', 'summary': 'Monday', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-05', 'recurrence_id': '20220905', 'summary': 'Mondays [edit]', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-12', 'recurrence_id': '20220912', 'summary': 'Mondays [edit]', 'uid': 'mock-uid-1', }), ]) # --- # name: test_edit_recurring_all_day_event_this_and_future[recur1] list([ dict({ 'dtstart': '2022-08-29', 'recurrence_id': '20220829', 'summary': 'Monday', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-05', 'recurrence_id': '20220905', 'summary': 'Mondays [edit]', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-12', 'recurrence_id': '20220912', 'summary': 'Mondays [edit]', 'uid': 'mock-uid-1', }), ]) # --- # name: test_edit_recurring_event[recur0] list([ dict({ 'dtstart': '2022-08-30T09:00:00', 'recurrence_id': '20220830T090000', 'summary': 'Tuesday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-06T09:00:00', 'recurrence_id': '20220906T090000', 'summary': 'Tuesday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-13T09:00:00', 'recurrence_id': '20220913T090000', 'summary': 'Tuesday meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_edit_recurring_event[recur1] list([ dict({ 'dtstart': '2022-08-30T09:00:00', 'recurrence_id': '20220830T090000', 'summary': 'Tuesday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-06T09:00:00', 'recurrence_id': '20220906T090000', 'summary': 'Tuesday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-13T09:00:00', 'recurrence_id': '20220913T090000', 'summary': 'Tuesday meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_edit_recurring_event_instance[recur0] list([ dict({ 'dtstart': '2022-08-29T09:00:00', 'recurrence_id': '20220829T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-06T09:00:00', 'recurrence_id': '20220905T090000', 'summary': 'Tuesday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-12T09:00:00', 'recurrence_id': '20220912T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_edit_recurring_event_instance[recur1] list([ dict({ 'dtstart': '2022-08-29T09:00:00', 'recurrence_id': '20220829T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-06T09:00:00', 'recurrence_id': '20220905T090000', 'summary': 'Tuesday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-12T09:00:00', 'recurrence_id': '20220912T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_edit_recurring_event_this_and_future[recur0] list([ dict({ 'dtstart': '2022-08-29T09:00:00', 'recurrence_id': '20220829T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-05T09:00:00', 'recurrence_id': '20220905T090000', 'summary': 'Team meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-12T09:00:00', 'recurrence_id': '20220912T090000', 'summary': 'Team meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_edit_recurring_event_this_and_future[recur1] list([ dict({ 'dtstart': '2022-08-29T09:00:00', 'recurrence_id': '20220829T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-05T09:00:00', 'recurrence_id': '20220905T090000', 'summary': 'Team meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-12T09:00:00', 'recurrence_id': '20220912T090000', 'summary': 'Team meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_edit_recurring_with_same_rrule list([ dict({ 'dtstart': '2022-08-30T09:00:00', 'recurrence_id': '20220830T090000', 'summary': 'Tuesday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-06T09:00:00', 'recurrence_id': '20220906T090000', 'summary': 'Tuesday meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_edit_todo list([ dict({ 'created': '2022-09-03T09:38:05+00:00', 'dtstamp': '2022-09-03T09:38:05+00:00', 'dtstart': '2022-08-28T09:00:00', 'due': '2022-08-29T09:00:00', 'sequence': 0, 'summary': 'Monday morning items', 'uid': 'mock-uid-1', }), dict({ 'created': '2022-09-03T09:38:05+00:00', 'dtstamp': '2022-09-03T09:38:05+00:00', 'dtstart': '2022-08-29T09:00:00', 'due': '2022-08-30T09:00:00', 'sequence': 0, 'summary': 'Tuesday morning items', 'uid': 'mock-uid-2', }), ]) # --- # name: test_edit_todo.1 list([ dict({ 'created': '2022-09-03T09:38:05+00:00', 'dtstamp': '2022-09-03T09:38:15+00:00', 'dtstart': '2022-08-28T09:00:00', 'due': '2022-08-29T09:05:00', 'last_modified': '2022-09-03T09:38:15+00:00', 'sequence': 1, 'summary': 'Monday morning items (Delayed)', 'uid': 'mock-uid-1', }), dict({ 'created': '2022-09-03T09:38:05+00:00', 'dtstamp': '2022-09-03T09:38:05+00:00', 'dtstart': '2022-08-29T09:00:00', 'due': '2022-08-30T09:00:00', 'sequence': 0, 'summary': 'Tuesday morning items', 'uid': 'mock-uid-2', }), ]) # --- # name: test_edit_todo_status[completed] list([ dict({ 'completed': '2022-09-03T09:38:15+00:00', 'created': '2022-09-03T09:38:05+00:00', 'dtstamp': '2022-09-03T09:38:15+00:00', 'dtstart': '2022-08-28T09:00:00', 'due': '2022-08-29T09:00:00', 'last_modified': '2022-09-03T09:38:15+00:00', 'sequence': 1, 'status': , 'summary': 'Monday morning items', 'uid': 'mock-uid-1', }), ]) # --- # name: test_edit_todo_status[completed_again] list([ dict({ 'completed': '2022-09-03T09:38:15+00:00', 'created': '2022-09-03T09:38:35+00:00', 'dtstamp': '2022-09-03T09:39:05+00:00', 'dtstart': '2022-08-28T09:00:00', 'due': '2022-08-29T09:05:00', 'last_modified': '2022-09-03T09:39:05+00:00', 'sequence': 3, 'status': , 'summary': 'Monday morning items (Delayed)', 'uid': 'mock-uid-1', }), ]) # --- # name: test_edit_todo_status[edit_summary] list([ dict({ 'completed': '2022-09-03T09:38:15+00:00', 'created': '2022-09-03T09:38:15+00:00', 'dtstamp': '2022-09-03T09:38:35+00:00', 'dtstart': '2022-08-28T09:00:00', 'due': '2022-08-29T09:05:00', 'last_modified': '2022-09-03T09:38:35+00:00', 'sequence': 2, 'status': , 'summary': 'Monday morning items (Delayed)', 'uid': 'mock-uid-1', }), ]) # --- # name: test_edit_todo_status[initial] list([ dict({ 'created': '2022-09-03T09:38:05+00:00', 'dtstamp': '2022-09-03T09:38:05+00:00', 'dtstart': '2022-08-28T09:00:00', 'due': '2022-08-29T09:00:00', 'sequence': 0, 'summary': 'Monday morning items', 'uid': 'mock-uid-1', }), ]) # --- # name: test_edit_todo_status[needs_action] list([ dict({ 'created': '2022-09-03T09:39:05+00:00', 'dtstamp': '2022-09-03T09:39:45+00:00', 'dtstart': '2022-08-28T09:00:00', 'due': '2022-08-29T09:05:00', 'last_modified': '2022-09-03T09:39:45+00:00', 'sequence': 4, 'status': , 'summary': 'Monday morning items (Delayed)', 'uid': 'mock-uid-1', }), ]) # --- # name: test_modify_todo_rrule_for_this_and_future[2024-01-05] list([ dict({ 'due': '2024-01-07', 'recurrence_id': '20240106', 'status': , 'summary': 'Wash car (Sa)', 'uid': 'mock-uid-1', }), ]) # --- # name: test_modify_todo_rrule_for_this_and_future[2024-01-12] list([ dict({ 'due': '2024-01-07', 'recurrence_id': '20240106', 'status': , 'summary': 'Wash car (Sa)', 'uid': 'mock-uid-1', }), ]) # --- # name: test_modify_todo_rrule_for_this_and_future[2024-01-19] list([ dict({ 'due': '2024-01-14', 'recurrence_id': '20240113', 'status': , 'summary': 'Wash car (Sa)', 'uid': 'mock-uid-1', }), ]) # --- # name: test_modify_todo_rrule_for_this_and_future[2024-01-26] list([ dict({ 'due': '2024-01-22', 'recurrence_id': '20240121', 'status': , 'summary': 'Wash car (Su)', 'uid': 'mock-uid-1', }), ]) # --- # name: test_modify_todo_rrule_for_this_and_future[ics] ''' BEGIN:VCALENDAR PRODID:-//example//1.2.3 VERSION:2.0 BEGIN:VTODO DTSTAMP:20220903T093805Z UID:mock-uid-1 CREATED:20220903T093805Z DTSTART:20240121 DUE:20240122 LAST-MODIFIED:20220903T093805Z RECURRENCE-ID:20240120 RRULE:FREQ=WEEKLY;COUNT=8;BYDAY=SU SEQUENCE:1 STATUS:NEEDS-ACTION SUMMARY:Wash car (Su) END:VTODO BEGIN:VTODO DTSTAMP:20220903T093805Z UID:mock-uid-1 CREATED:20220903T093805Z DTSTART:20240106 DUE:20240107 LAST-MODIFIED:20220903T093805Z RRULE:FREQ=WEEKLY;UNTIL=20240119;BYDAY=SA SEQUENCE:0 STATUS:NEEDS-ACTION SUMMARY:Wash car (Sa) END:VTODO END:VCALENDAR ''' # --- # name: test_recurring_event[start0-end0-recur0] list([ dict({ 'dtstart': '2022-08-29T09:00:00', 'recurrence_id': '20220829T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-05T09:00:00', 'recurrence_id': '20220905T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-12T09:00:00', 'recurrence_id': '20220912T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-19T09:00:00', 'recurrence_id': '20220919T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-26T09:00:00', 'recurrence_id': '20220926T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_recurring_event[start1-end1-recur1] list([ dict({ 'dtstart': '2022-08-29T09:00:00', 'recurrence_id': '20220829T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-05T09:00:00', 'recurrence_id': '20220905T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-12T09:00:00', 'recurrence_id': '20220912T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-19T09:00:00', 'recurrence_id': '20220919T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-26T09:00:00', 'recurrence_id': '20220926T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_recurring_event[start2-end2-recur2] list([ dict({ 'dtstart': '2022-08-29T09:00:00-07:00', 'recurrence_id': '20220829T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-05T09:00:00-07:00', 'recurrence_id': '20220905T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-12T09:00:00-07:00', 'recurrence_id': '20220912T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-19T09:00:00-07:00', 'recurrence_id': '20220919T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), dict({ 'dtstart': '2022-09-26T09:00:00-07:00', 'recurrence_id': '20220926T090000', 'summary': 'Monday meeting', 'uid': 'mock-uid-1', }), ]) # --- # name: test_recurring_todo_item_edit_series.3 ''' BEGIN:VCALENDAR PRODID:-//example//1.2.3 VERSION:2.0 BEGIN:VTODO DTSTAMP:20240109T100005Z UID:mock-uid-1 COMPLETED:20240109T100005Z CREATED:20240109T100005Z DTSTART:20240109 DUE:20240110 LAST-MODIFIED:20240109T100005Z RRULE:FREQ=DAILY;COUNT=10 SEQUENCE:1 STATUS:COMPLETED SUMMARY:Walk dog END:VTODO END:VCALENDAR ''' # --- # name: test_recurring_todo_item_edit_series[completed] list([ dict({ 'due': '2024-01-10', 'recurrence_id': '20240109', 'status': , 'summary': 'Walk dog', 'uid': 'mock-uid-1', }), ]) # --- # name: test_recurring_todo_item_edit_series[initial] list([ dict({ 'due': '2024-01-10', 'recurrence_id': '20240109', 'status': , 'summary': 'Walk dog', 'uid': 'mock-uid-1', }), ]) # --- # name: test_recurring_todo_item_edit_series[next_instance] list([ dict({ 'due': '2024-01-11', 'recurrence_id': '20240110', 'status': , 'summary': 'Walk dog', 'uid': 'mock-uid-1', }), ]) # --- # name: test_recurring_todo_item_edit_single[completed] list([ dict({ 'due': '2024-01-10', 'recurrence_id': '20240109', 'status': , 'summary': 'Walk dog', 'uid': 'mock-uid-1', }), ]) # --- # name: test_recurring_todo_item_edit_single[deleted_series_ics] ''' BEGIN:VCALENDAR PRODID:-//example//1.2.3 VERSION:2.0 END:VCALENDAR ''' # --- # name: test_recurring_todo_item_edit_single[initial] list([ dict({ 'due': '2024-01-10', 'recurrence_id': '20240109', 'status': , 'summary': 'Walk dog', 'uid': 'mock-uid-1', }), ]) # --- # name: test_recurring_todo_item_edit_single[next_instance] list([ dict({ 'due': '2024-01-11', 'recurrence_id': '20240110', 'status': , 'summary': 'Walk dog', 'uid': 'mock-uid-1', }), ]) # --- # name: test_recurring_todo_item_edit_single[next_instance_completed] list([ dict({ 'due': '2024-01-10', 'recurrence_id': '20240110', 'status': , 'summary': 'Walk cat', 'uid': 'mock-uid-1', }), ]) # --- # name: test_recurring_todo_item_edit_single[next_instance_deleted] list([ dict({ 'due': '2024-01-10', 'recurrence_id': '20240109', 'status': , 'summary': 'Walk dog', 'uid': 'mock-uid-1', }), ]) # --- # name: test_recurring_todo_item_edit_single[next_instance_deleted_ics] ''' BEGIN:VCALENDAR PRODID:-//example//1.2.3 VERSION:2.0 BEGIN:VTODO DTSTAMP:20240109T100005Z UID:mock-uid-1 COMPLETED:20240109T100005Z CREATED:20240109T100005Z DTSTART:20240109 DUE:20240110 LAST-MODIFIED:20240109T100005Z RECURRENCE-ID:20240109 SEQUENCE:1 STATUS:COMPLETED SUMMARY:Walk dog END:VTODO BEGIN:VTODO DTSTAMP:20240109T100005Z UID:mock-uid-1 CREATED:20240109T100005Z DTSTART:20240109 DUE:20240110 RRULE:FREQ=DAILY;COUNT=10 EXDATE:20240109 EXDATE:20240110 SEQUENCE:0 STATUS:NEEDS-ACTION SUMMARY:Walk dog END:VTODO END:VCALENDAR ''' # --- # name: test_recurring_todo_item_edit_single[result_ics] ''' BEGIN:VCALENDAR PRODID:-//example//1.2.3 VERSION:2.0 BEGIN:VTODO DTSTAMP:20240109T100005Z UID:mock-uid-1 COMPLETED:20240109T100005Z CREATED:20240109T100005Z DTSTART:20240109 DUE:20240110 LAST-MODIFIED:20240109T100005Z RECURRENCE-ID:20240109 SEQUENCE:1 STATUS:COMPLETED SUMMARY:Walk dog END:VTODO BEGIN:VTODO DTSTAMP:20240110T100000Z UID:mock-uid-1 COMPLETED:20240110T100000Z CREATED:20240109T100005Z DTSTART:20240109 DUE:20240110 LAST-MODIFIED:20240110T100000Z RECURRENCE-ID:20240110 EXDATE:20240109 SEQUENCE:1 STATUS:COMPLETED SUMMARY:Walk dog END:VTODO BEGIN:VTODO DTSTAMP:20240109T100005Z UID:mock-uid-1 CREATED:20240109T100005Z DTSTART:20240109 DUE:20240110 RRULE:FREQ=DAILY;COUNT=10 EXDATE:20240109 EXDATE:20240110 SEQUENCE:0 STATUS:NEEDS-ACTION SUMMARY:Walk dog END:VTODO END:VCALENDAR ''' # --- # name: test_recurring_todo_item_edit_single[result_ics_modified] ''' BEGIN:VCALENDAR PRODID:-//example//1.2.3 VERSION:2.0 BEGIN:VTODO DTSTAMP:20240109T100005Z UID:mock-uid-1 COMPLETED:20240109T100005Z CREATED:20240109T100005Z DTSTART:20240109 DUE:20240110 LAST-MODIFIED:20240109T100005Z RECURRENCE-ID:20240109 SEQUENCE:1 STATUS:COMPLETED SUMMARY:Walk dog END:VTODO BEGIN:VTODO DTSTAMP:20240110T100000Z UID:mock-uid-1 COMPLETED:20240110T100000Z CREATED:20240110T100000Z DTSTART:20240109 DUE:20240110 LAST-MODIFIED:20240110T100000Z RECURRENCE-ID:20240110 EXDATE:20240109 SEQUENCE:2 STATUS:COMPLETED SUMMARY:Walk cat END:VTODO BEGIN:VTODO DTSTAMP:20240109T100005Z UID:mock-uid-1 CREATED:20240109T100005Z DTSTART:20240109 DUE:20240110 RRULE:FREQ=DAILY;COUNT=10 EXDATE:20240109 EXDATE:20240110 SEQUENCE:0 STATUS:NEEDS-ACTION SUMMARY:Walk dog END:VTODO END:VCALENDAR ''' # --- allenporter-ical-fe8800b/tests/compat/000077500000000000000000000000001510550726100200145ustar00rootroot00000000000000allenporter-ical-fe8800b/tests/compat/__snapshots__/000077500000000000000000000000001510550726100226325ustar00rootroot00000000000000allenporter-ical-fe8800b/tests/compat/__snapshots__/test_make_compat.ambr000066400000000000000000000117761510550726100270300ustar00rootroot00000000000000# serializer version: 1 # name: test_make_compat[office_356_custom_timezone] ''' BEGIN:VCALENDAR METHOD:PUBLISH PRODID:Microsoft Exchange Server 2010 VERSION:2.0 BEGIN:VEVENT DTSTAMP:20250508T124800Z UID:040000008200E00074C5B7101A82E0080000000032281124038FDA01000000000000000 010000000BE5193C6B024BB458D91AD30FFFD71BF DTSTART;TZID=CET:20240502T110000 DTEND;TZID=CET:20240502T120000 SUMMARY:Zajęty CLASS:PUBLIC PRIORITY:5 RRULE:FREQ=WEEKLY;UNTIL=20240502T090000Z;INTERVAL=2;BYDAY=TH SEQUENCE:46 STATUS:CONFIRMED TRANSP:OPAQUE END:VEVENT BEGIN:VEVENT DTSTAMP:20250508T124800Z UID:040000008200E00074C5B7101A82E0080000000088E248D3DC4EDA01000000000000000 010000000FC71CD8C02E70C40B4D15EB1417E2150 DTSTART;TZID=CET:20240521T150000 DTEND;TZID=CET:20240521T160000 SUMMARY:Wstępna akceptacja CLASS:PUBLIC PRIORITY:5 RRULE:FREQ=WEEKLY;UNTIL=20260505T130000Z;INTERVAL=2;BYDAY=TU EXDATE;TZID=CET:20241231T150000 EXDATE;TZID=CET:20250225T150000 SEQUENCE:5 STATUS:CONFIRMED TRANSP:OPAQUE END:VEVENT BEGIN:VTIMEZONE TZID:Central European Standard Time BEGIN:STANDARD DTSTART:16010101T030000 TZOFFSETTO:+0100 TZOFFSETFROM:+0200 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 END:STANDARD BEGIN:DAYLIGHT DTSTART:16010101T020000 TZOFFSETTO:+0200 TZOFFSETFROM:+0100 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:Eastern Standard Time BEGIN:STANDARD DTSTART:16010101T020000 TZOFFSETTO:-0500 TZOFFSETFROM:-0400 RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11 END:STANDARD BEGIN:DAYLIGHT DTSTART:16010101T020000 TZOFFSETTO:-0400 TZOFFSETFROM:-0500 RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:Customized Time Zone BEGIN:STANDARD DTSTART:16010101T030000 TZOFFSETTO:+0100 TZOFFSETFROM:+0200 RRULE:FREQ=YEARLY;BYDAY=4SU;BYMONTH=10 END:STANDARD BEGIN:DAYLIGHT DTSTART:16010101T020000 TZOFFSETTO:+0200 TZOFFSETFROM:+0100 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:South Africa Standard Time BEGIN:STANDARD DTSTART:16010101T000000 TZOFFSETTO:+0200 TZOFFSETFROM:+0200 END:STANDARD BEGIN:DAYLIGHT DTSTART:16010101T000000 TZOFFSETTO:+0200 TZOFFSETFROM:+0200 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:Greenwich Standard Time BEGIN:STANDARD DTSTART:16010101T000000 TZOFFSETTO:+0000 TZOFFSETFROM:+0000 END:STANDARD BEGIN:DAYLIGHT DTSTART:16010101T000000 TZOFFSETTO:+0000 TZOFFSETFROM:+0000 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:Turkey Standard Time BEGIN:STANDARD DTSTART:16010101T000000 TZOFFSETTO:+0300 TZOFFSETFROM:+0300 END:STANDARD BEGIN:DAYLIGHT DTSTART:16010101T000000 TZOFFSETTO:+0300 TZOFFSETFROM:+0300 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:GMT Standard Time BEGIN:STANDARD DTSTART:16010101T020000 TZOFFSETTO:+0000 TZOFFSETFROM:+0100 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 END:STANDARD BEGIN:DAYLIGHT DTSTART:16010101T010000 TZOFFSETTO:+0100 TZOFFSETFROM:+0000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:SE Asia Standard Time BEGIN:STANDARD DTSTART:16010101T000000 TZOFFSETTO:+0700 TZOFFSETFROM:+0700 END:STANDARD BEGIN:DAYLIGHT DTSTART:16010101T000000 TZOFFSETTO:+0700 TZOFFSETFROM:+0700 END:DAYLIGHT END:VTIMEZONE END:VCALENDAR ''' # --- # name: test_make_compat[office_365_extended_timezone] ''' BEGIN:VCALENDAR METHOD:PUBLISH PRODID:Microsoft Exchange Server 2010 VERSION:2.0 BEGIN:VEVENT DTSTAMP:20250417T155647Z UID:040000008200E00074C5B7101A82E00800000000687C546B5596DA01000000000000000 010000000309AE93C8C3A94489F90ADBEA30C2F2B DTSTART;TZID=CET:20240426T140000 DTEND;TZID=CET:20240426T150000 SUMMARY:Uffe CLASS:PUBLIC LOCATION: PRIORITY:5 SEQUENCE:0 STATUS:CONFIRMED TRANSP:OPAQUE END:VEVENT END:VCALENDAR ''' # --- # name: test_make_compat[office_365_invalid_timezone] ''' BEGIN:VCALENDAR METHOD:PUBLISH PRODID:Microsoft Exchange Server 2010 VERSION:2.0 BEGIN:VEVENT DTSTAMP:20250417T155647Z UID:040000008200E00074C5B7101A82E00800000000687C546B5596DA01000000000000000 010000000309AE93C8C3A94489F90ADBEA30C2F2B DTSTART:20240426T140000 DTEND:20240426T150000 SUMMARY:Uffe CLASS:PUBLIC LOCATION: PRIORITY:5 SEQUENCE:0 STATUS:CONFIRMED TRANSP:OPAQUE END:VEVENT BEGIN:VTIMEZONE TZID:W. Europe Standard Time BEGIN:STANDARD DTSTART:16010101T030000 TZOFFSETTO:+0100 TZOFFSETFROM:+0200 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 END:STANDARD BEGIN:DAYLIGHT DTSTART:16010101T020000 TZOFFSETTO:+0200 TZOFFSETFROM:+0100 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:UTC BEGIN:STANDARD DTSTART:16010101T000000 TZOFFSETTO:+0000 TZOFFSETFROM:+0000 END:STANDARD BEGIN:DAYLIGHT DTSTART:16010101T000000 TZOFFSETTO:+0000 TZOFFSETFROM:+0000 END:DAYLIGHT END:VTIMEZONE END:VCALENDAR ''' # --- allenporter-ical-fe8800b/tests/compat/__snapshots__/test_timezone_compat.ambr000066400000000000000000000031121510550726100277260ustar00rootroot00000000000000# serializer version: 1 # name: test_extended_timezone_compat ''' BEGIN:VCALENDAR METHOD:PUBLISH PRODID:Microsoft Exchange Server 2010 VERSION:2.0 BEGIN:VEVENT DTSTAMP:20250417T155647Z UID:040000008200E00074C5B7101A82E00800000000687C546B5596DA01000000000000000 010000000309AE93C8C3A94489F90ADBEA30C2F2B DTSTART;TZID=CET:20240426T140000 DTEND;TZID=CET:20240426T150000 SUMMARY:Uffe CLASS:PUBLIC LOCATION: PRIORITY:5 SEQUENCE:0 STATUS:CONFIRMED TRANSP:OPAQUE END:VEVENT END:VCALENDAR ''' # --- # name: test_invalid_timezone_compat ''' BEGIN:VCALENDAR METHOD:PUBLISH PRODID:Microsoft Exchange Server 2010 VERSION:2.0 BEGIN:VEVENT DTSTAMP:20250417T155647Z UID:040000008200E00074C5B7101A82E00800000000687C546B5596DA01000000000000000 010000000309AE93C8C3A94489F90ADBEA30C2F2B DTSTART:20240426T140000 DTEND:20240426T150000 SUMMARY:Uffe CLASS:PUBLIC LOCATION: PRIORITY:5 SEQUENCE:0 STATUS:CONFIRMED TRANSP:OPAQUE END:VEVENT BEGIN:VTIMEZONE TZID:W. Europe Standard Time BEGIN:STANDARD DTSTART:16010101T030000 TZOFFSETTO:+0100 TZOFFSETFROM:+0200 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 END:STANDARD BEGIN:DAYLIGHT DTSTART:16010101T020000 TZOFFSETTO:+0200 TZOFFSETFROM:+0100 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:UTC BEGIN:STANDARD DTSTART:16010101T000000 TZOFFSETTO:+0000 TZOFFSETFROM:+0000 END:STANDARD BEGIN:DAYLIGHT DTSTART:16010101T000000 TZOFFSETTO:+0000 TZOFFSETFROM:+0000 END:DAYLIGHT END:VTIMEZONE END:VCALENDAR ''' # --- allenporter-ical-fe8800b/tests/compat/test_make_compat.py000066400000000000000000000041251510550726100237070ustar00rootroot00000000000000"""Tests for all compatibility modules.""" import pathlib import pytest from syrupy import SnapshotAssertion from ical.exceptions import CalendarParseError from ical.calendar_stream import CalendarStream, IcsCalendarStream from ical.store import TodoStore from ical.compat import enable_compat_mode, timezone_compat TESTDATA_PATH = pathlib.Path("tests/compat/testdata/") TESTDATA_FILES = list(sorted(TESTDATA_PATH.glob("*.ics"))) TESTDATA_IDS = [x.stem for x in TESTDATA_FILES] @pytest.mark.parametrize("filename", TESTDATA_FILES, ids=TESTDATA_IDS) def test_make_compat(filename: pathlib.Path, snapshot: SnapshotAssertion) -> None: """Test to read golden files and verify they are parsed.""" with filename.open() as ics_file: ics = ics_file.read() with enable_compat_mode(ics) as compat_ics: assert timezone_compat.is_allow_invalid_timezones_enabled() assert timezone_compat.is_extended_timezones_enabled() calendar = IcsCalendarStream.calendar_from_ics(compat_ics) new_ics = IcsCalendarStream.calendar_to_ics(calendar) assert new_ics == snapshot # The output content can be parsed back correctly IcsCalendarStream.calendar_from_ics(new_ics) @pytest.mark.parametrize("filename", TESTDATA_FILES, ids=TESTDATA_IDS) def test_parse_failure(filename: pathlib.Path, snapshot: SnapshotAssertion) -> None: """Test to read golden files and verify they are parsed.""" with filename.open() as ics_file: ics = ics_file.read() with pytest.raises(CalendarParseError): IcsCalendarStream.calendar_from_ics(ics) @pytest.mark.parametrize( "ics", [ "invalid", "PRODID:not-exchange", "BEGIN:VCALENDAR\nPRODID:not-exchange\nVERSION:2.0\nEND:VCALENDAR", ], ) def test_make_compat_not_enabled(ics: str) -> None: """Test to read golden files and verify they are parsed.""" with enable_compat_mode(ics) as compat_ics: assert compat_ics == ics assert not timezone_compat.is_allow_invalid_timezones_enabled() assert not timezone_compat.is_extended_timezones_enabled() allenporter-ical-fe8800b/tests/compat/test_timezone_compat.py000066400000000000000000000037521510550726100246310ustar00rootroot00000000000000"""Tests for the extended timezone component.""" import pathlib import pytest from syrupy import SnapshotAssertion from ical.exceptions import CalendarParseError from ical.calendar_stream import CalendarStream, IcsCalendarStream from ical.store import TodoStore from ical.compat import timezone_compat TESTDATA_PATH = pathlib.Path("tests/compat/testdata/") OFFICE_365_EXTENDED_TIMEZONE = TESTDATA_PATH / "office_365_extended_timezone.ics" OFFICE_365_INVALID_TIMEZONE = TESTDATA_PATH / "office_365_invalid_timezone.ics" def test_extended_timezone_fail() -> None: """Test Office 365 extended timezone.""" with pytest.raises( CalendarParseError, match="Expected DATE-TIME TZID value 'W. Europe Standard Time' to be valid timezone", ): IcsCalendarStream.calendar_from_ics( OFFICE_365_EXTENDED_TIMEZONE.read_text(encoding="utf-8") ) def test_extended_timezone_compat(snapshot: SnapshotAssertion) -> None: """Test Office 365 extended timezone with compat enabled.""" with timezone_compat.enable_extended_timezones(): calendar = IcsCalendarStream.calendar_from_ics( OFFICE_365_EXTENDED_TIMEZONE.read_text(encoding="utf-8") ) assert IcsCalendarStream.calendar_to_ics(calendar) == snapshot def test_invalid_timezone_fail() -> None: """Test Office 365 invalid timezone.""" with pytest.raises( CalendarParseError, match="Expected DATE-TIME TZID value 'Customized Time Zone' to be valid timezone", ): IcsCalendarStream.calendar_from_ics( OFFICE_365_INVALID_TIMEZONE.read_text(encoding="utf-8") ) def test_invalid_timezone_compat(snapshot: SnapshotAssertion) -> None: """Test Office 365 invalid timezone.""" with timezone_compat.enable_allow_invalid_timezones(): calendar = IcsCalendarStream.calendar_from_ics( OFFICE_365_INVALID_TIMEZONE.read_text(encoding="utf-8") ) assert IcsCalendarStream.calendar_to_ics(calendar) == snapshot allenporter-ical-fe8800b/tests/compat/testdata/000077500000000000000000000000001510550726100216255ustar00rootroot00000000000000allenporter-ical-fe8800b/tests/compat/testdata/office_356_custom_timezone.ics000066400000000000000000000101721510550726100274620ustar00rootroot00000000000000BEGIN:VCALENDAR METHOD:PUBLISH PRODID:Microsoft Exchange Server 2010 VERSION:2.0 X-WR-CALNAME:Kalendarz BEGIN:VTIMEZONE TZID:Central European Standard Time BEGIN:STANDARD DTSTART:16010101T030000 TZOFFSETFROM:+0200 TZOFFSETTO:+0100 RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10 END:STANDARD BEGIN:DAYLIGHT DTSTART:16010101T020000 TZOFFSETFROM:+0100 TZOFFSETTO:+0200 RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:Eastern Standard Time BEGIN:STANDARD DTSTART:16010101T020000 TZOFFSETFROM:-0400 TZOFFSETTO:-0500 RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=1SU;BYMONTH=11 END:STANDARD BEGIN:DAYLIGHT DTSTART:16010101T020000 TZOFFSETFROM:-0500 TZOFFSETTO:-0400 RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=2SU;BYMONTH=3 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:Customized Time Zone BEGIN:STANDARD DTSTART:16010101T030000 TZOFFSETFROM:+0200 TZOFFSETTO:+0100 RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=4SU;BYMONTH=10 END:STANDARD BEGIN:DAYLIGHT DTSTART:16010101T020000 TZOFFSETFROM:+0100 TZOFFSETTO:+0200 RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:South Africa Standard Time BEGIN:STANDARD DTSTART:16010101T000000 TZOFFSETFROM:+0200 TZOFFSETTO:+0200 END:STANDARD BEGIN:DAYLIGHT DTSTART:16010101T000000 TZOFFSETFROM:+0200 TZOFFSETTO:+0200 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:Greenwich Standard Time BEGIN:STANDARD DTSTART:16010101T000000 TZOFFSETFROM:+0000 TZOFFSETTO:+0000 END:STANDARD BEGIN:DAYLIGHT DTSTART:16010101T000000 TZOFFSETFROM:+0000 TZOFFSETTO:+0000 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:Turkey Standard Time BEGIN:STANDARD DTSTART:16010101T000000 TZOFFSETFROM:+0300 TZOFFSETTO:+0300 END:STANDARD BEGIN:DAYLIGHT DTSTART:16010101T000000 TZOFFSETFROM:+0300 TZOFFSETTO:+0300 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:GMT Standard Time BEGIN:STANDARD DTSTART:16010101T020000 TZOFFSETFROM:+0100 TZOFFSETTO:+0000 RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10 END:STANDARD BEGIN:DAYLIGHT DTSTART:16010101T010000 TZOFFSETFROM:+0000 TZOFFSETTO:+0100 RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:SE Asia Standard Time BEGIN:STANDARD DTSTART:16010101T000000 TZOFFSETFROM:+0700 TZOFFSETTO:+0700 END:STANDARD BEGIN:DAYLIGHT DTSTART:16010101T000000 TZOFFSETFROM:+0700 TZOFFSETTO:+0700 END:DAYLIGHT END:VTIMEZONE BEGIN:VEVENT RRULE:FREQ=WEEKLY;UNTIL=20240502T090000Z;INTERVAL=2;BYDAY=TH;WKST=SU UID:040000008200E00074C5B7101A82E0080000000032281124038FDA01000000000000000 010000000BE5193C6B024BB458D91AD30FFFD71BF SUMMARY:Zajęty DTSTART;TZID=Central European Standard Time:20240502T110000 DTEND;TZID=Central European Standard Time:20240502T120000 CLASS:PUBLIC PRIORITY:5 DTSTAMP:20250508T124800Z TRANSP:OPAQUE STATUS:CONFIRMED SEQUENCE:46 X-MICROSOFT-CDO-APPT-SEQUENCE:46 X-MICROSOFT-CDO-BUSYSTATUS:BUSY X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY X-MICROSOFT-CDO-ALLDAYEVENT:FALSE X-MICROSOFT-CDO-IMPORTANCE:1 X-MICROSOFT-CDO-INSTTYPE:1 X-MICROSOFT-DONOTFORWARDMEETING:FALSE X-MICROSOFT-DISALLOW-COUNTER:FALSE X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT X-MICROSOFT-ISRESPONSEREQUESTED:FALSE END:VEVENT BEGIN:VEVENT RRULE:FREQ=WEEKLY;UNTIL=20260505T130000Z;INTERVAL=2;BYDAY=TU;WKST=SU EXDATE;TZID=Central European Standard Time:20241231T150000,20250225T150000 UID:040000008200E00074C5B7101A82E0080000000088E248D3DC4EDA01000000000000000 010000000FC71CD8C02E70C40B4D15EB1417E2150 SUMMARY:Wstępna akceptacja DTSTART;TZID=Central European Standard Time:20240521T150000 DTEND;TZID=Central European Standard Time:20240521T160000 CLASS:PUBLIC PRIORITY:5 DTSTAMP:20250508T124800Z TRANSP:OPAQUE STATUS:CONFIRMED SEQUENCE:5 X-MICROSOFT-CDO-APPT-SEQUENCE:5 X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY X-MICROSOFT-CDO-ALLDAYEVENT:FALSE X-MICROSOFT-CDO-IMPORTANCE:1 X-MICROSOFT-CDO-INSTTYPE:1 X-MICROSOFT-DONOTFORWARDMEETING:FALSE X-MICROSOFT-DISALLOW-COUNTER:FALSE X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT X-MICROSOFT-ISRESPONSEREQUESTED:FALSE END:VEVENT END:VCALENDAR allenporter-ical-fe8800b/tests/compat/testdata/office_365_extended_timezone.ics000066400000000000000000000014671510550726100277570ustar00rootroot00000000000000BEGIN:VCALENDAR METHOD:PUBLISH PRODID:Microsoft Exchange Server 2010 VERSION:2.0 X-WR-CALNAME:Kalender BEGIN:VEVENT UID:040000008200E00074C5B7101A82E00800000000687C546B5596DA01000000000000000 010000000309AE93C8C3A94489F90ADBEA30C2F2B SUMMARY:Uffe DTSTART;TZID=W. Europe Standard Time:20240426T140000 DTEND;TZID=W. Europe Standard Time:20240426T150000 CLASS:PUBLIC PRIORITY:5 DTSTAMP:20250417T155647Z TRANSP:OPAQUE STATUS:CONFIRMED SEQUENCE:0 LOCATION: X-MICROSOFT-CDO-APPT-SEQUENCE:0 X-MICROSOFT-CDO-BUSYSTATUS:BUSY X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY X-MICROSOFT-CDO-ALLDAYEVENT:FALSE X-MICROSOFT-CDO-IMPORTANCE:1 X-MICROSOFT-CDO-INSTTYPE:0 X-MICROSOFT-DONOTFORWARDMEETING:FALSE X-MICROSOFT-DISALLOW-COUNTER:FALSE X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT X-MICROSOFT-ISRESPONSEREQUESTED:FALSE END:VEVENT END:VCALENDAR allenporter-ical-fe8800b/tests/compat/testdata/office_365_invalid_timezone.ics000066400000000000000000000025301510550726100275750ustar00rootroot00000000000000BEGIN:VCALENDAR METHOD:PUBLISH PRODID:Microsoft Exchange Server 2010 VERSION:2.0 X-WR-CALNAME:Kalender BEGIN:VTIMEZONE TZID:W. Europe Standard Time BEGIN:STANDARD DTSTART:16010101T030000 TZOFFSETFROM:+0200 TZOFFSETTO:+0100 RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10 END:STANDARD BEGIN:DAYLIGHT DTSTART:16010101T020000 TZOFFSETFROM:+0100 TZOFFSETTO:+0200 RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:UTC BEGIN:STANDARD DTSTART:16010101T000000 TZOFFSETFROM:+0000 TZOFFSETTO:+0000 END:STANDARD BEGIN:DAYLIGHT DTSTART:16010101T000000 TZOFFSETFROM:+0000 TZOFFSETTO:+0000 END:DAYLIGHT END:VTIMEZONE BEGIN:VEVENT UID:040000008200E00074C5B7101A82E00800000000687C546B5596DA01000000000000000 010000000309AE93C8C3A94489F90ADBEA30C2F2B SUMMARY:Uffe DTSTART;TZID=Customized Time Zone:20240426T140000 DTEND;TZID=Customized Time Zone:20240426T150000 CLASS:PUBLIC PRIORITY:5 DTSTAMP:20250417T155647Z TRANSP:OPAQUE STATUS:CONFIRMED SEQUENCE:0 LOCATION: X-MICROSOFT-CDO-APPT-SEQUENCE:0 X-MICROSOFT-CDO-BUSYSTATUS:BUSY X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY X-MICROSOFT-CDO-ALLDAYEVENT:FALSE X-MICROSOFT-CDO-IMPORTANCE:1 X-MICROSOFT-CDO-INSTTYPE:0 X-MICROSOFT-DONOTFORWARDMEETING:FALSE X-MICROSOFT-DISALLOW-COUNTER:FALSE X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT X-MICROSOFT-ISRESPONSEREQUESTED:FALSE END:VEVENT END:VCALENDAR allenporter-ical-fe8800b/tests/conftest.py000066400000000000000000000017551510550726100207400ustar00rootroot00000000000000"""Test fixtures.""" from collections.abc import Generator import dataclasses import json from typing import Any from unittest.mock import patch from pydantic_core import to_jsonable_python import pytest PRODID = "-//example//1.2.3" class DataclassEncoder(json.JSONEncoder): """Class that can dump data classes as dict for comparison to golden.""" def default(self, o: Any) -> Any: if dataclasses.is_dataclass(o): # Omit empty return {k: v for (k, v) in dataclasses.asdict(o).items() if v} if isinstance(o, dict): return {k: v for (k, v) in o.items() if v} return to_jsonable_python(o) @pytest.fixture def json_encoder() -> json.JSONEncoder: """Fixture that creates a json encoder.""" return DataclassEncoder() @pytest.fixture(autouse=True) def mock_prodid() -> Generator[None, None, None]: """Mock out the prodid used in tests.""" with patch("ical.calendar.prodid_factory", return_value=PRODID): yield allenporter-ical-fe8800b/tests/examples/000077500000000000000000000000001510550726100203475ustar00rootroot00000000000000allenporter-ical-fe8800b/tests/examples/__snapshots__/000077500000000000000000000000001510550726100231655ustar00rootroot00000000000000allenporter-ical-fe8800b/tests/examples/__snapshots__/test_ics_examples.ambr000066400000000000000000002262331510550726100275530ustar00rootroot00000000000000# serializer version: 1 # name: test_iterate_events[apple_ical] list([ Event(dtstamp=datetime.datetime(2022, 9, 25, 19, 15, 45, tzinfo=datetime.timezone.utc), uid='A409C8CF-31E9-4234-BE8A-6CE2B6BB875A', dtstart=datetime.datetime(2022, 9, 12, 9, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Los_Angeles')), dtend=datetime.datetime(2022, 9, 12, 10, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Los_Angeles')), duration=None, summary='New Event', attendees=[], categories=[], classification=None, comment=[], contacts=[], created=datetime.datetime(2022, 9, 25, 19, 15, 43, tzinfo=datetime.timezone.utc), description=None, geo=None, last_modified=datetime.datetime(2022, 9, 25, 19, 15, 43, tzinfo=datetime.timezone.utc), location=None, organizer=None, priority=None, recurrence_id=None, related_to=[], related=[], resources=[], rrule=None, rdate=[], exdate=[], request_status=None, sequence=1, status=None, transparency=None, url=None, extras=[], alarm=[]), Event(dtstamp=datetime.datetime(2022, 9, 25, 19, 22, 30, tzinfo=datetime.timezone.utc), uid='E53B06A1-9F72-41D9-9446-68E335D2D4F4', dtstart=datetime.datetime(2022, 9, 13, 9, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Los_Angeles')), dtend=datetime.datetime(2022, 9, 13, 10, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Los_Angeles')), duration=None, summary='Daily', attendees=[], categories=[], classification=None, comment=[], contacts=[], created=datetime.datetime(2022, 9, 25, 19, 15, 59, tzinfo=datetime.timezone.utc), description=None, geo=None, last_modified=datetime.datetime(2022, 9, 24, 17, 6, 16, tzinfo=datetime.timezone.utc), location=None, organizer=None, priority=None, recurrence_id='20220913T090000', related_to=[], related=[], resources=[], rrule=Recur(freq=, until=datetime.datetime(2022, 9, 26, 15, 59, 59, tzinfo=datetime.timezone.utc), count=None, interval=1, by_weekday=[], by_month_day=[], by_month=[], by_setpos=[]), rdate=[], exdate=[], request_status=None, sequence=1, status=None, transparency=None, url=None, extras=[], alarm=[]), Event(dtstamp=datetime.datetime(2022, 9, 25, 19, 22, 30, tzinfo=datetime.timezone.utc), uid='E53B06A1-9F72-41D9-9446-68E335D2D4F4', dtstart=datetime.datetime(2022, 9, 14, 9, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Los_Angeles')), dtend=datetime.datetime(2022, 9, 14, 10, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Los_Angeles')), duration=None, summary='Daily', attendees=[], categories=[], classification=None, comment=[], contacts=[], created=datetime.datetime(2022, 9, 25, 19, 15, 59, tzinfo=datetime.timezone.utc), description=None, geo=None, last_modified=datetime.datetime(2022, 9, 24, 17, 6, 16, tzinfo=datetime.timezone.utc), location=None, organizer=None, priority=None, recurrence_id='20220914T090000', related_to=[], related=[], resources=[], rrule=Recur(freq=, until=datetime.datetime(2022, 9, 26, 15, 59, 59, tzinfo=datetime.timezone.utc), count=None, interval=1, by_weekday=[], by_month_day=[], by_month=[], by_setpos=[]), rdate=[], exdate=[], request_status=None, sequence=1, status=None, transparency=None, url=None, extras=[], alarm=[]), Event(dtstamp=datetime.datetime(2022, 9, 25, 19, 22, 30, tzinfo=datetime.timezone.utc), uid='E53B06A1-9F72-41D9-9446-68E335D2D4F4', dtstart=datetime.datetime(2022, 9, 15, 9, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Los_Angeles')), dtend=datetime.datetime(2022, 9, 15, 10, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Los_Angeles')), duration=None, summary='Daily', attendees=[], categories=[], classification=None, comment=[], contacts=[], created=datetime.datetime(2022, 9, 25, 19, 15, 59, tzinfo=datetime.timezone.utc), description=None, geo=None, last_modified=datetime.datetime(2022, 9, 24, 17, 6, 16, tzinfo=datetime.timezone.utc), location=None, organizer=None, priority=None, recurrence_id='20220915T090000', related_to=[], related=[], resources=[], rrule=Recur(freq=, until=datetime.datetime(2022, 9, 26, 15, 59, 59, tzinfo=datetime.timezone.utc), count=None, interval=1, by_weekday=[], by_month_day=[], by_month=[], by_setpos=[]), rdate=[], exdate=[], request_status=None, sequence=1, status=None, transparency=None, url=None, extras=[], alarm=[]), Event(dtstamp=datetime.datetime(2022, 9, 25, 19, 22, 30, tzinfo=datetime.timezone.utc), uid='E53B06A1-9F72-41D9-9446-68E335D2D4F4', dtstart=datetime.datetime(2022, 9, 16, 9, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Los_Angeles')), dtend=datetime.datetime(2022, 9, 16, 10, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Los_Angeles')), duration=None, summary='Daily', attendees=[], categories=[], classification=None, comment=[], contacts=[], created=datetime.datetime(2022, 9, 25, 19, 15, 59, tzinfo=datetime.timezone.utc), description=None, geo=None, last_modified=datetime.datetime(2022, 9, 24, 17, 6, 16, tzinfo=datetime.timezone.utc), location=None, organizer=None, priority=None, recurrence_id='20220916T090000', related_to=[], related=[], resources=[], rrule=Recur(freq=, until=datetime.datetime(2022, 9, 26, 15, 59, 59, tzinfo=datetime.timezone.utc), count=None, interval=1, by_weekday=[], by_month_day=[], by_month=[], by_setpos=[]), rdate=[], exdate=[], request_status=None, sequence=1, status=None, transparency=None, url=None, extras=[], alarm=[]), ]) # --- # name: test_iterate_events[extended_values] list([ ]) # --- # name: test_iterate_events[google_calendar_invalid_offset] list([ Event(dtstamp=datetime.datetime(2025, 5, 11, 23, 39, 45, tzinfo=datetime.timezone.utc), uid='KOrganizer-854889822.731//199594j26139acq39nj78tbhac@group.calendar.google.com', dtstart=datetime.datetime(2003, 2, 3, 17, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Argentina/Buenos_Aires')), dtend=datetime.datetime(2003, 2, 3, 19, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Argentina/Buenos_Aires')), duration=None, summary='Event Summary', attendees=[], categories=[], classification=, comment=[], contacts=[], created=datetime.datetime(2006, 3, 15, 21, 4, 21, tzinfo=datetime.timezone.utc), description=None, geo=None, last_modified=datetime.datetime(2023, 2, 20, 3, 40, 45, tzinfo=datetime.timezone.utc), location=None, organizer=None, priority=None, recurrence_id='20030203T170000', related_to=[], related=[], resources=[], rrule=Recur(freq=, until=datetime.datetime(2003, 2, 13, 16, 0, tzinfo=datetime.timezone.utc), count=None, interval=1, by_weekday=[], by_month_day=[], by_month=[], by_setpos=[]), rdate=[], exdate=[datetime.datetime(2003, 2, 8, 17, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Argentina/Buenos_Aires')), datetime.datetime(2003, 2, 9, 17, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Argentina/Buenos_Aires'))], request_status=None, sequence=3, status=, transparency='OPAQUE', url=None, extras=[], alarm=[]), Event(dtstamp=datetime.datetime(2025, 5, 11, 23, 39, 45, tzinfo=datetime.timezone.utc), uid='KOrganizer-854889822.731//199594j26139acq39nj78tbhac@group.calendar.google.com', dtstart=datetime.datetime(2003, 2, 4, 17, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Argentina/Buenos_Aires')), dtend=datetime.datetime(2003, 2, 4, 19, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Argentina/Buenos_Aires')), duration=None, summary='Event Summary', attendees=[], categories=[], classification=, comment=[], contacts=[], created=datetime.datetime(2006, 3, 15, 21, 4, 21, tzinfo=datetime.timezone.utc), description=None, geo=None, last_modified=datetime.datetime(2023, 2, 20, 3, 40, 45, tzinfo=datetime.timezone.utc), location=None, organizer=None, priority=None, recurrence_id='20030204T170000', related_to=[], related=[], resources=[], rrule=Recur(freq=, until=datetime.datetime(2003, 2, 13, 16, 0, tzinfo=datetime.timezone.utc), count=None, interval=1, by_weekday=[], by_month_day=[], by_month=[], by_setpos=[]), rdate=[], exdate=[datetime.datetime(2003, 2, 8, 17, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Argentina/Buenos_Aires')), datetime.datetime(2003, 2, 9, 17, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Argentina/Buenos_Aires'))], request_status=None, sequence=3, status=, transparency='OPAQUE', url=None, extras=[], alarm=[]), Event(dtstamp=datetime.datetime(2025, 5, 11, 23, 39, 45, tzinfo=datetime.timezone.utc), uid='KOrganizer-854889822.731//199594j26139acq39nj78tbhac@group.calendar.google.com', dtstart=datetime.datetime(2003, 2, 5, 17, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Argentina/Buenos_Aires')), dtend=datetime.datetime(2003, 2, 5, 19, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Argentina/Buenos_Aires')), duration=None, summary='Event Summary', attendees=[], categories=[], classification=, comment=[], contacts=[], created=datetime.datetime(2006, 3, 15, 21, 4, 21, tzinfo=datetime.timezone.utc), description=None, geo=None, last_modified=datetime.datetime(2023, 2, 20, 3, 40, 45, tzinfo=datetime.timezone.utc), location=None, organizer=None, priority=None, recurrence_id='20030205T170000', related_to=[], related=[], resources=[], rrule=Recur(freq=, until=datetime.datetime(2003, 2, 13, 16, 0, tzinfo=datetime.timezone.utc), count=None, interval=1, by_weekday=[], by_month_day=[], by_month=[], by_setpos=[]), rdate=[], exdate=[datetime.datetime(2003, 2, 8, 17, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Argentina/Buenos_Aires')), datetime.datetime(2003, 2, 9, 17, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Argentina/Buenos_Aires'))], request_status=None, sequence=3, status=, transparency='OPAQUE', url=None, extras=[], alarm=[]), Event(dtstamp=datetime.datetime(2025, 5, 11, 23, 39, 45, tzinfo=datetime.timezone.utc), uid='KOrganizer-854889822.731//199594j26139acq39nj78tbhac@group.calendar.google.com', dtstart=datetime.datetime(2003, 2, 6, 17, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Argentina/Buenos_Aires')), dtend=datetime.datetime(2003, 2, 6, 19, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Argentina/Buenos_Aires')), duration=None, summary='Event Summary', attendees=[], categories=[], classification=, comment=[], contacts=[], created=datetime.datetime(2006, 3, 15, 21, 4, 21, tzinfo=datetime.timezone.utc), description=None, geo=None, last_modified=datetime.datetime(2023, 2, 20, 3, 40, 45, tzinfo=datetime.timezone.utc), location=None, organizer=None, priority=None, recurrence_id='20030206T170000', related_to=[], related=[], resources=[], rrule=Recur(freq=, until=datetime.datetime(2003, 2, 13, 16, 0, tzinfo=datetime.timezone.utc), count=None, interval=1, by_weekday=[], by_month_day=[], by_month=[], by_setpos=[]), rdate=[], exdate=[datetime.datetime(2003, 2, 8, 17, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Argentina/Buenos_Aires')), datetime.datetime(2003, 2, 9, 17, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Argentina/Buenos_Aires'))], request_status=None, sequence=3, status=, transparency='OPAQUE', url=None, extras=[], alarm=[]), Event(dtstamp=datetime.datetime(2025, 5, 11, 23, 39, 45, tzinfo=datetime.timezone.utc), uid='KOrganizer-854889822.731//199594j26139acq39nj78tbhac@group.calendar.google.com', dtstart=datetime.datetime(2003, 2, 7, 17, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Argentina/Buenos_Aires')), dtend=datetime.datetime(2003, 2, 7, 19, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Argentina/Buenos_Aires')), duration=None, summary='Event Summary', attendees=[], categories=[], classification=, comment=[], contacts=[], created=datetime.datetime(2006, 3, 15, 21, 4, 21, tzinfo=datetime.timezone.utc), description=None, geo=None, last_modified=datetime.datetime(2023, 2, 20, 3, 40, 45, tzinfo=datetime.timezone.utc), location=None, organizer=None, priority=None, recurrence_id='20030207T170000', related_to=[], related=[], resources=[], rrule=Recur(freq=, until=datetime.datetime(2003, 2, 13, 16, 0, tzinfo=datetime.timezone.utc), count=None, interval=1, by_weekday=[], by_month_day=[], by_month=[], by_setpos=[]), rdate=[], exdate=[datetime.datetime(2003, 2, 8, 17, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Argentina/Buenos_Aires')), datetime.datetime(2003, 2, 9, 17, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Argentina/Buenos_Aires'))], request_status=None, sequence=3, status=, transparency='OPAQUE', url=None, extras=[], alarm=[]), ]) # --- # name: test_iterate_events[google_calendar_public_holidays] list([ Event(dtstamp=datetime.datetime(2022, 7, 30, 15, 3, 14, tzinfo=datetime.timezone.utc), uid='20210101_fokaslga04sgnfruusv1tomhs4@google.com', dtstart=datetime.date(2021, 1, 1), dtend=datetime.date(2021, 1, 2), duration=None, summary="New Year's Day", attendees=[], categories=[], classification=, comment=[], contacts=[], created=datetime.datetime(2021, 9, 10, 19, 0, 34, tzinfo=datetime.timezone.utc), description='Public holiday', geo=None, last_modified=datetime.datetime(2021, 9, 10, 19, 0, 34, tzinfo=datetime.timezone.utc), location=None, organizer=None, priority=None, recurrence_id=None, related_to=[], related=[], resources=[], rrule=None, rdate=[], exdate=[], request_status=None, sequence=0, status=, transparency='TRANSPARENT', url=None, extras=[], alarm=[]), Event(dtstamp=datetime.datetime(2022, 7, 30, 15, 3, 14, tzinfo=datetime.timezone.utc), uid='20210118_1po1bsgspnatou2qf9gahqc7rk@google.com', dtstart=datetime.date(2021, 1, 18), dtend=datetime.date(2021, 1, 19), duration=None, summary='Martin Luther King Jr. Day', attendees=[], categories=[], classification=, comment=[], contacts=[], created=datetime.datetime(2021, 9, 10, 19, 0, 23, tzinfo=datetime.timezone.utc), description='Public holiday', geo=None, last_modified=datetime.datetime(2021, 9, 10, 19, 0, 23, tzinfo=datetime.timezone.utc), location=None, organizer=None, priority=None, recurrence_id=None, related_to=[], related=[], resources=[], rrule=None, rdate=[], exdate=[], request_status=None, sequence=0, status=, transparency='TRANSPARENT', url=None, extras=[], alarm=[]), Event(dtstamp=datetime.datetime(2022, 7, 30, 15, 3, 14, tzinfo=datetime.timezone.utc), uid='20210120_vtpup7q2um8qbtqccvosgkvmgs@google.com', dtstart=datetime.date(2021, 1, 20), dtend=datetime.date(2021, 1, 21), duration=None, summary='Inauguration Day (regional holiday)', attendees=[], categories=[], classification=, comment=[], contacts=[], created=datetime.datetime(2021, 9, 10, 19, 0, 25, tzinfo=datetime.timezone.utc), description='Public holiday in District of Columbia, Maryland, Virginia', geo=None, last_modified=datetime.datetime(2021, 9, 10, 19, 0, 25, tzinfo=datetime.timezone.utc), location=None, organizer=None, priority=None, recurrence_id=None, related_to=[], related=[], resources=[], rrule=None, rdate=[], exdate=[], request_status=None, sequence=0, status=, transparency='TRANSPARENT', url=None, extras=[], alarm=[]), Event(dtstamp=datetime.datetime(2022, 7, 30, 15, 3, 14, tzinfo=datetime.timezone.utc), uid='20210201_0j09arjnv4r8gm0oldpkgua31s@google.com', dtstart=datetime.date(2021, 2, 1), dtend=datetime.date(2021, 2, 2), duration=None, summary='First Day of Black History Month', attendees=[], categories=[], classification=, comment=[], contacts=[], created=datetime.datetime(2021, 9, 10, 19, 0, 23, tzinfo=datetime.timezone.utc), description='Observance\nTo hide observances, go to Google Calendar Settings > Holidays in United States', geo=None, last_modified=datetime.datetime(2021, 9, 10, 19, 0, 23, tzinfo=datetime.timezone.utc), location=None, organizer=None, priority=None, recurrence_id=None, related_to=[], related=[], resources=[], rrule=None, rdate=[], exdate=[], request_status=None, sequence=0, status=, transparency='TRANSPARENT', url=None, extras=[], alarm=[]), Event(dtstamp=datetime.datetime(2022, 7, 30, 15, 3, 14, tzinfo=datetime.timezone.utc), uid='20210214_reo046tof5lk55gc5kkrlkj778@google.com', dtstart=datetime.date(2021, 2, 14), dtend=datetime.date(2021, 2, 15), duration=None, summary="Valentine's Day", attendees=[], categories=[], classification=, comment=[], contacts=[], created=datetime.datetime(2021, 9, 10, 19, 0, 23, tzinfo=datetime.timezone.utc), description='Observance\nTo hide observances, go to Google Calendar Settings > Holidays in United States', geo=None, last_modified=datetime.datetime(2021, 9, 10, 19, 0, 23, tzinfo=datetime.timezone.utc), location=None, organizer=None, priority=None, recurrence_id=None, related_to=[], related=[], resources=[], rrule=None, rdate=[], exdate=[], request_status=None, sequence=0, status=, transparency='TRANSPARENT', url=None, extras=[], alarm=[]), ]) # --- # name: test_iterate_events[recurring_event] list([ Event(dtstamp=datetime.datetime(2025, 5, 4, 15, 1, 36, tzinfo=datetime.timezone.utc), uid='v8rukz8ier7ijmn@google.com', dtstart=datetime.date(2025, 5, 5), dtend=datetime.date(2025, 5, 6), duration=None, summary='Some Event', attendees=[], categories=[], classification=None, comment=[], contacts=[], created=datetime.datetime(2025, 5, 4, 14, 56, 10, tzinfo=datetime.timezone.utc), description=None, geo=None, last_modified=datetime.datetime(2025, 5, 4, 14, 57, 29, tzinfo=datetime.timezone.utc), location=None, organizer=None, priority=None, recurrence_id='20250505', related_to=[], related=[], resources=[], rrule=Recur(freq=, until=None, count=None, interval=1, by_weekday=[], by_month_day=[], by_month=[], by_setpos=[]), rdate=[], exdate=[], request_status=None, sequence=0, status=, transparency='TRANSPARENT', url=None, extras=[], alarm=[]), Event(dtstamp=datetime.datetime(2025, 5, 4, 15, 1, 36, tzinfo=datetime.timezone.utc), uid='v8rukz8ier7ijmn@google.com', dtstart=datetime.date(2025, 5, 6), dtend=datetime.date(2025, 5, 7), duration=None, summary='Some Event', attendees=[], categories=[], classification=None, comment=[], contacts=[], created=datetime.datetime(2025, 5, 4, 14, 56, 10, tzinfo=datetime.timezone.utc), description=None, geo=None, last_modified=datetime.datetime(2025, 5, 4, 14, 57, 29, tzinfo=datetime.timezone.utc), location=None, organizer=None, priority=None, recurrence_id='20250506', related_to=[], related=[], resources=[], rrule=Recur(freq=, until=None, count=None, interval=1, by_weekday=[], by_month_day=[], by_month=[], by_setpos=[]), rdate=[], exdate=[], request_status=None, sequence=0, status=, transparency='TRANSPARENT', url=None, extras=[], alarm=[]), Event(dtstamp=datetime.datetime(2025, 5, 4, 15, 1, 36, tzinfo=datetime.timezone.utc), uid='v8rukz8ier7ijmn@google.com', dtstart=datetime.date(2025, 5, 7), dtend=datetime.date(2025, 5, 8), duration=None, summary='Some Modified Event', attendees=[], categories=[], classification=None, comment=[], contacts=[], created=datetime.datetime(2025, 5, 4, 14, 56, 10, tzinfo=datetime.timezone.utc), description=None, geo=None, last_modified=datetime.datetime(2025, 5, 4, 14, 57, 38, tzinfo=datetime.timezone.utc), location=None, organizer=None, priority=None, recurrence_id='20250507', related_to=[], related=[], resources=[], rrule=None, rdate=[], exdate=[], request_status=None, sequence=0, status=, transparency='TRANSPARENT', url=None, extras=[], alarm=[]), Event(dtstamp=datetime.datetime(2025, 5, 4, 15, 1, 36, tzinfo=datetime.timezone.utc), uid='v8rukz8ier7ijmn@google.com', dtstart=datetime.date(2025, 5, 7), dtend=datetime.date(2025, 5, 8), duration=None, summary='Some Event', attendees=[], categories=[], classification=None, comment=[], contacts=[], created=datetime.datetime(2025, 5, 4, 14, 56, 10, tzinfo=datetime.timezone.utc), description=None, geo=None, last_modified=datetime.datetime(2025, 5, 4, 14, 57, 29, tzinfo=datetime.timezone.utc), location=None, organizer=None, priority=None, recurrence_id='20250507', related_to=[], related=[], resources=[], rrule=Recur(freq=, until=None, count=None, interval=1, by_weekday=[], by_month_day=[], by_month=[], by_setpos=[]), rdate=[], exdate=[], request_status=None, sequence=0, status=, transparency='TRANSPARENT', url=None, extras=[], alarm=[]), Event(dtstamp=datetime.datetime(2025, 5, 4, 15, 1, 36, tzinfo=datetime.timezone.utc), uid='v8rukz8ier7ijmn@google.com', dtstart=datetime.date(2025, 5, 8), dtend=datetime.date(2025, 5, 9), duration=None, summary='Some Event', attendees=[], categories=[], classification=None, comment=[], contacts=[], created=datetime.datetime(2025, 5, 4, 14, 56, 10, tzinfo=datetime.timezone.utc), description=None, geo=None, last_modified=datetime.datetime(2025, 5, 4, 14, 57, 29, tzinfo=datetime.timezone.utc), location=None, organizer=None, priority=None, recurrence_id='20250508', related_to=[], related=[], resources=[], rrule=Recur(freq=, until=None, count=None, interval=1, by_weekday=[], by_month_day=[], by_month=[], by_setpos=[]), rdate=[], exdate=[], request_status=None, sequence=0, status=, transparency='TRANSPARENT', url=None, extras=[], alarm=[]), ]) # --- # name: test_iterate_events[store_edit_bugs] list([ Event(dtstamp=datetime.datetime(2021, 8, 5, 16, 33, 53, tzinfo=datetime.timezone.utc), uid='HX7KXxJM9ZyvyH8D00BMVP-K9drT@proton.me', dtstart=datetime.datetime(2021, 9, 4, 11, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Amsterdam')), dtend=datetime.datetime(2021, 9, 4, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Amsterdam')), duration=None, summary='My Event', attendees=[], categories=[], classification=None, comment=[], contacts=[], created=None, description='Example', geo=None, last_modified=None, location=None, organizer=None, priority=None, recurrence_id='20210904T110000', related_to=[], related=[], resources=[], rrule=Recur(freq=, until=None, count=None, interval=1, by_weekday=[], by_month_day=[], by_month=[], by_setpos=[]), rdate=[], exdate=[], request_status=None, sequence=0, status=, transparency=None, url=None, extras=[], alarm=[]), Event(dtstamp=datetime.datetime(2021, 8, 5, 16, 33, 53, tzinfo=datetime.timezone.utc), uid='HX7KXxJM9ZyvyH8D00BMVP-K9drT@proton.me', dtstart=datetime.datetime(2021, 9, 11, 11, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Amsterdam')), dtend=datetime.datetime(2021, 9, 11, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Amsterdam')), duration=None, summary='My Event', attendees=[], categories=[], classification=None, comment=[], contacts=[], created=None, description='Example', geo=None, last_modified=None, location=None, organizer=None, priority=None, recurrence_id='20210911T110000', related_to=[], related=[], resources=[], rrule=Recur(freq=, until=None, count=None, interval=1, by_weekday=[], by_month_day=[], by_month=[], by_setpos=[]), rdate=[], exdate=[], request_status=None, sequence=0, status=, transparency=None, url=None, extras=[], alarm=[]), Event(dtstamp=datetime.datetime(2021, 8, 5, 16, 33, 53, tzinfo=datetime.timezone.utc), uid='HX7KXxJM9ZyvyH8D00BMVP-K9drT@proton.me', dtstart=datetime.datetime(2021, 9, 18, 11, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Amsterdam')), dtend=datetime.datetime(2021, 9, 18, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Amsterdam')), duration=None, summary='My Event', attendees=[], categories=[], classification=None, comment=[], contacts=[], created=None, description='Example', geo=None, last_modified=None, location=None, organizer=None, priority=None, recurrence_id='20210918T110000', related_to=[], related=[], resources=[], rrule=Recur(freq=, until=None, count=None, interval=1, by_weekday=[], by_month_day=[], by_month=[], by_setpos=[]), rdate=[], exdate=[], request_status=None, sequence=0, status=, transparency=None, url=None, extras=[], alarm=[]), Event(dtstamp=datetime.datetime(2021, 8, 5, 16, 33, 53, tzinfo=datetime.timezone.utc), uid='HX7KXxJM9ZyvyH8D00BMVP-K9drT@proton.me', dtstart=datetime.datetime(2021, 9, 25, 11, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Amsterdam')), dtend=datetime.datetime(2021, 9, 25, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Amsterdam')), duration=None, summary='My Event', attendees=[], categories=[], classification=None, comment=[], contacts=[], created=None, description='Example', geo=None, last_modified=None, location=None, organizer=None, priority=None, recurrence_id='20210925T110000', related_to=[], related=[], resources=[], rrule=Recur(freq=, until=None, count=None, interval=1, by_weekday=[], by_month_day=[], by_month=[], by_setpos=[]), rdate=[], exdate=[], request_status=None, sequence=0, status=, transparency=None, url=None, extras=[], alarm=[]), Event(dtstamp=datetime.datetime(2021, 8, 5, 16, 33, 53, tzinfo=datetime.timezone.utc), uid='HX7KXxJM9ZyvyH8D00BMVP-K9drT@proton.me', dtstart=datetime.datetime(2021, 10, 2, 11, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Amsterdam')), dtend=datetime.datetime(2021, 10, 2, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Amsterdam')), duration=None, summary='My Event', attendees=[], categories=[], classification=None, comment=[], contacts=[], created=None, description='Example', geo=None, last_modified=None, location=None, organizer=None, priority=None, recurrence_id='20211002T110000', related_to=[], related=[], resources=[], rrule=Recur(freq=, until=None, count=None, interval=1, by_weekday=[], by_month_day=[], by_month=[], by_setpos=[]), rdate=[], exdate=[], request_status=None, sequence=0, status=, transparency=None, url=None, extras=[], alarm=[]), ]) # --- # name: test_parse[apple_ical] ''' BEGIN:VCALENDAR PRODID:-//caldav.icloud.com//CALDAVJ 2514B607//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20220925T191543Z UID:001BE545-52F9-4099-ACFC-A14FF63C4701 DTSTART;TZID=America/Los_Angeles:20220927T090000 DTEND;TZID=America/Los_Angeles:20220927T100000 SUMMARY:New Event CREATED:20220925T061541Z LAST-MODIFIED:20220925T061541Z SEQUENCE:1 END:VEVENT BEGIN:VEVENT DTSTAMP:20231015T180900Z UID:020C71BB-A981-4520-83E9-98E488A60863 DTSTART:20231011 DTEND:20231013 SUMMARY:Multi-day event CREATED:20231015T180900Z SEQUENCE:0 END:VEVENT BEGIN:VEVENT DTSTAMP:20231015T180532Z UID:3B4E9E16-8D79-422F-B48F-888861099B5B DTSTART:20231015 DTEND:20231018 SUMMARY:Multi-day event CREATED:20231015T180532Z SEQUENCE:0 END:VEVENT BEGIN:VEVENT DTSTAMP:20220924T152626Z UID:543E8A23-5A51-4489-8117-55241E8A1E30 DTSTART;TZID=America/Los_Angeles:20220920T090000 DTEND;TZID=America/Los_Angeles:20220920T110000 SUMMARY:Example CREATED:20220924T152551Z DESCRIPTION:Test description LAST-MODIFIED:20220924T152623Z SEQUENCE:1 END:VEVENT BEGIN:VEVENT DTSTAMP:20220924T170617Z UID:6D0A3855-9577-40D3-AE87-9624657C7561 DTSTART;TZID=America/Los_Angeles:20220926T090000 DTEND;TZID=America/Los_Angeles:20220926T100000 SUMMARY:Daily CREATED:20220924T170609Z LAST-MODIFIED:20220925T191650Z RRULE:FREQ=DAILY SEQUENCE:1 END:VEVENT BEGIN:VEVENT DTSTAMP:20220925T191545Z UID:A409C8CF-31E9-4234-BE8A-6CE2B6BB875A DTSTART;TZID=America/Los_Angeles:20220912T090000 DTEND;TZID=America/Los_Angeles:20220912T100000 SUMMARY:New Event CREATED:20220925T191543Z LAST-MODIFIED:20220925T191543Z SEQUENCE:1 END:VEVENT BEGIN:VEVENT DTSTAMP:20220925T192230Z UID:E53B06A1-9F72-41D9-9446-68E335D2D4F4 DTSTART;TZID=America/Los_Angeles:20220913T090000 DTEND;TZID=America/Los_Angeles:20220913T100000 SUMMARY:Daily CREATED:20220925T191559Z LAST-MODIFIED:20220924T170616Z RRULE:FREQ=DAILY;UNTIL=20220926T155959Z SEQUENCE:1 END:VEVENT BEGIN:VEVENT DTSTAMP:20220924T162627Z UID:F17B5DBD-1915-4654-90A5-1D068739A75F DTSTART;TZID=America/Los_Angeles:20220922T090000 DTEND;TZID=America/Los_Angeles:20220922T100000 SUMMARY:bar CREATED:20220924T162621Z DESCRIPTION:foo LAST-MODIFIED:20220924T162627Z SEQUENCE:1 END:VEVENT BEGIN:VTIMEZONE TZID:America/Los_Angeles BEGIN:STANDARD DTSTART:18831118T120702 TZOFFSETTO:-0800 TZOFFSETFROM:-0752 RDATE:18831118T120702 TZNAME:PST END:STANDARD BEGIN:STANDARD DTSTART:19181027T020000 TZOFFSETTO:-0800 TZOFFSETFROM:-0700 RRULE:FREQ=YEARLY;UNTIL=19191026T090000Z;BYDAY=-1SU;BYMONTH=10 TZNAME:PST END:STANDARD BEGIN:STANDARD DTSTART:19450930T020000 TZOFFSETTO:-0800 TZOFFSETFROM:-0700 RDATE:19450930T020000 RDATE:19490101T020000 TZNAME:PST END:STANDARD BEGIN:STANDARD DTSTART:19460101T000000 TZOFFSETTO:-0800 TZOFFSETFROM:-0800 RDATE:19460101T000000 RDATE:19670101T000000 TZNAME:PST END:STANDARD BEGIN:STANDARD DTSTART:19500924T020000 TZOFFSETTO:-0800 TZOFFSETFROM:-0700 RRULE:FREQ=YEARLY;UNTIL=19610924T090000Z;BYDAY=-1SU;BYMONTH=9 TZNAME:PST END:STANDARD BEGIN:STANDARD DTSTART:19621028T020000 TZOFFSETTO:-0800 TZOFFSETFROM:-0700 RRULE:FREQ=YEARLY;UNTIL=19661030T090000Z;BYDAY=-1SU;BYMONTH=10 TZNAME:PST END:STANDARD BEGIN:STANDARD DTSTART:19671029T020000 TZOFFSETTO:-0800 TZOFFSETFROM:-0700 RRULE:FREQ=YEARLY;UNTIL=20061029T090000Z;BYDAY=-1SU;BYMONTH=10 TZNAME:PST END:STANDARD BEGIN:STANDARD DTSTART:20071104T020000 TZOFFSETTO:-0800 TZOFFSETFROM:-0700 RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11 TZNAME:PST END:STANDARD BEGIN:DAYLIGHT DTSTART:19180331T020000 TZOFFSETTO:-0700 TZOFFSETFROM:-0800 RRULE:FREQ=YEARLY;UNTIL=19190330T100000Z;BYDAY=-1SU;BYMONTH=3 TZNAME:PDT END:DAYLIGHT BEGIN:DAYLIGHT DTSTART:19420209T020000 TZOFFSETTO:-0700 TZOFFSETFROM:-0800 RDATE:19420209T020000 TZNAME:PWT END:DAYLIGHT BEGIN:DAYLIGHT DTSTART:19450814T160000 TZOFFSETTO:-0700 TZOFFSETFROM:-0700 RDATE:19450814T160000 TZNAME:PPT END:DAYLIGHT BEGIN:DAYLIGHT DTSTART:19480314T020100 TZOFFSETTO:-0700 TZOFFSETFROM:-0800 RDATE:19480314T020100 RDATE:19740106T020000 RDATE:19750223T020000 TZNAME:PDT END:DAYLIGHT BEGIN:DAYLIGHT DTSTART:19500430T010000 TZOFFSETTO:-0700 TZOFFSETFROM:-0800 RRULE:FREQ=YEARLY;UNTIL=19660424T090000Z;BYDAY=-1SU;BYMONTH=4 TZNAME:PDT END:DAYLIGHT BEGIN:DAYLIGHT DTSTART:19670430T020000 TZOFFSETTO:-0700 TZOFFSETFROM:-0800 RRULE:FREQ=YEARLY;UNTIL=19730429T100000Z;BYDAY=-1SU;BYMONTH=4 TZNAME:PDT END:DAYLIGHT BEGIN:DAYLIGHT DTSTART:19760425T020000 TZOFFSETTO:-0700 TZOFFSETFROM:-0800 RRULE:FREQ=YEARLY;UNTIL=19860427T100000Z;BYDAY=-1SU;BYMONTH=4 TZNAME:PDT END:DAYLIGHT BEGIN:DAYLIGHT DTSTART:19870405T020000 TZOFFSETTO:-0700 TZOFFSETFROM:-0800 RRULE:FREQ=YEARLY;UNTIL=20060402T100000Z;BYDAY=1SU;BYMONTH=4 TZNAME:PDT END:DAYLIGHT BEGIN:DAYLIGHT DTSTART:20070311T020000 TZOFFSETTO:-0700 TZOFFSETFROM:-0800 RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3 TZNAME:PDT END:DAYLIGHT END:VTIMEZONE END:VCALENDAR ''' # --- # name: test_parse[extended_values] ''' BEGIN:VCALENDAR PRODID:-//github.com/allenporter/ical//8.2.0//EN VERSION:2.0 BEGIN:VTODO DTSTAMP:20250313T123432Z UID:20070313T123432Z-456553@example.com CATEGORIES:FAMILY CATEGORIES:FINANCE DUE:20070501 STATUS:NEEDS-ACTION SUMMARY:Submit Quebec Income Tax Return for 2006 END:VTODO END:VCALENDAR ''' # --- # name: test_parse[google_calendar_invalid_offset] ''' BEGIN:VCALENDAR CALSCALE:GREGORIAN METHOD:PUBLISH PRODID:-//Google Inc//Google Calendar 70.9054//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20250511T233945Z UID:KOrganizer-854889822.731//199594j26139acq39nj78tbhac@group.calendar.goo gle.com DTSTART;TZID=America/Argentina/Buenos_Aires:20030203T170000 DTEND;TZID=America/Argentina/Buenos_Aires:20030203T190000 SUMMARY:Event Summary CLASS:PUBLIC CREATED:20060315T210421Z LAST-MODIFIED:20230220T034045Z RRULE:FREQ=DAILY;UNTIL=20030213T160000Z EXDATE;TZID=America/Argentina/Buenos_Aires:20030208T170000 EXDATE;TZID=America/Argentina/Buenos_Aires:20030209T170000 SEQUENCE:3 STATUS:TENTATIVE TRANSP:OPAQUE END:VEVENT BEGIN:VTIMEZONE TZID:America/Sao_Paulo BEGIN:STANDARD DTSTART:19700101T000000 TZOFFSETTO:-0300 TZOFFSETFROM:-0300 TZNAME:GMT-3 END:STANDARD END:VTIMEZONE BEGIN:VTIMEZONE TZID:America/Argentina/Buenos_Aires BEGIN:STANDARD DTSTART:19700101T000000 TZOFFSETTO:-0300 TZOFFSETFROM:-0300 TZNAME:GMT-3 END:STANDARD END:VTIMEZONE END:VCALENDAR ''' # --- # name: test_parse[google_calendar_public_holidays] ''' BEGIN:VCALENDAR CALSCALE:GREGORIAN METHOD:PUBLISH PRODID:-//Google Inc//Google Calendar 70.9054//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20231110_rhv0a9kca8mmmdo4ei84ubu46s@google.com DTSTART:20231110 DTEND:20231111 SUMMARY:Veterans Day (substitute) CLASS:PUBLIC CREATED:20220126T031327Z DESCRIPTION:Public holiday LAST-MODIFIED:20220126T031327Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20230102_qn82d48cimkdg6jtog71ssphi8@google.com DTSTART:20230102 DTEND:20230103 SUMMARY:New Year's Day (substitute) CLASS:PUBLIC CREATED:20220126T031327Z DESCRIPTION:Public holiday LAST-MODIFIED:20220126T031327Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20211231_pjp0d08vkhqsv10an7hs3u2a9c@google.com DTSTART:20211231 DTEND:20220101 SUMMARY:New Year's Day (substitute) CLASS:PUBLIC CREATED:20220126T031327Z DESCRIPTION:Public holiday LAST-MODIFIED:20220126T031327Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20220620_2ggfpo9bnaj019jh2jn4m15934@google.com DTSTART:20220620 DTEND:20220621 SUMMARY:Juneteenth (substitute) CLASS:PUBLIC CREATED:20220126T031327Z DESCRIPTION:Public holiday LAST-MODIFIED:20220126T031327Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210618_vg7iiogg0qtjhpp3mrs1oeq48s@google.com DTSTART:20210618 DTEND:20210619 SUMMARY:Juneteenth (substitute) CLASS:PUBLIC CREATED:20220126T031327Z DESCRIPTION:Public holiday LAST-MODIFIED:20220126T031327Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210705_2vtd20oge5alkavuvoogkf021k@google.com DTSTART:20210705 DTEND:20210706 SUMMARY:Independence Day (substitute) CLASS:PUBLIC CREATED:20220126T031327Z DESCRIPTION:Public holiday LAST-MODIFIED:20220126T031327Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20221226_j7qpbluailblij0c1i09uuia8o@google.com DTSTART:20221226 DTEND:20221227 SUMMARY:Christmas Day (substitute) CLASS:PUBLIC CREATED:20220126T031327Z DESCRIPTION:Public holiday LAST-MODIFIED:20220126T031327Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20211224_9fvbilr0017a8t853k466rquoc@google.com DTSTART:20211224 DTEND:20211225 SUMMARY:Christmas Day (substitute) CLASS:PUBLIC CREATED:20220126T031327Z DESCRIPTION:Public holiday LAST-MODIFIED:20220126T031327Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20230514_4tku8pd1d4vqvrompkbqt51bps@google.com DTSTART:20230514 DTEND:20230515 SUMMARY:Mother's Day CLASS:PUBLIC CREATED:20210910T190037Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190037Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20220117_a8pam0i5kuqajqn3gih7bsdfn4@google.com DTSTART:20220117 DTEND:20220118 SUMMARY:Martin Luther King Jr. Day CLASS:PUBLIC CREATED:20210910T190037Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190037Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20220704_7mjropb9h0lmq9902t7jhanq94@google.com DTSTART:20220704 DTEND:20220705 SUMMARY:Independence Day CLASS:PUBLIC CREATED:20210910T190037Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190037Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20211031_hb7ms1o1dfan5l41e7npt91sks@google.com DTSTART:20211031 DTEND:20211101 SUMMARY:Halloween CLASS:PUBLIC CREATED:20210910T190037Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190037Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210915_mki3pbd3g88fds6gfomm419oqg@google.com DTSTART:20210915 DTEND:20210916 SUMMARY:First Day of Hispanic Heritage Month CLASS:PUBLIC CREATED:20210910T190037Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190037Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20211102_00haicklogosrcbanisb2cudd4@google.com DTSTART:20211102 DTEND:20211103 SUMMARY:Election Day CLASS:PUBLIC CREATED:20210910T190037Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190037Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20211107_n3rolco9cpvq95g7nqvlqsf0l0@google.com DTSTART:20211107 DTEND:20211108 SUMMARY:Daylight Saving Time ends CLASS:PUBLIC CREATED:20210910T190037Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190037Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20211225_56c03granps4dfloccthnu4288@google.com DTSTART:20211225 DTEND:20211226 SUMMARY:Christmas Day CLASS:PUBLIC CREATED:20210910T190037Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190037Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20211125_342uhr2rqf1qtvaqlg7nf58l94@google.com DTSTART:20211125 DTEND:20211126 SUMMARY:Thanksgiving Day CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20230418_fdjpuvbba1bjjqobbvpsf062fs@google.com DTSTART:20230418 DTEND:20230419 SUMMARY:Tax Day CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20220317_mnqa8ubhi14rjesehu00kn46fg@google.com DTSTART:20220317 DTEND:20220318 SUMMARY:St. Patrick's Day CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210101_fokaslga04sgnfruusv1tomhs4@google.com DTSTART:20210101 DTEND:20210102 SUMMARY:New Year's Day CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20221125_5fsp6cfd26fr9m158ot932b994@google.com DTSTART:20221125 DTEND:20221126 SUMMARY:Native American Heritage Day CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20220508_5j9tt75njq2mc9s84ktghv5uo4@google.com DTSTART:20220508 DTEND:20220509 SUMMARY:Mother's Day CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20220530_mubrcfb1ema52icfs7jvo52i8o@google.com DTSTART:20220530 DTEND:20220531 SUMMARY:Memorial Day CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20230601_2vhknhkbc0vkcd1ks1orb0qtq4@google.com DTSTART:20230601 DTEND:20230602 SUMMARY:First Day of LGBTQ+ Pride Month CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20230501_k826tj3euiqb7bd2bknnvrduvs@google.com DTSTART:20230501 DTEND:20230502 SUMMARY:First Day of Asian Pacific American Heritage Month CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20231107_fc8f1530s41qftcnc9c75jccok@google.com DTSTART:20231107 DTEND:20231108 SUMMARY:Election Day CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210404_u6jdgtbhpnu5uff5es0g2u55p8@google.com DTSTART:20210404 DTEND:20210405 SUMMARY:Easter Sunday CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20231105_drikm9rqmroskv6c07ug7t5l8o@google.com DTSTART:20231105 DTEND:20231106 SUMMARY:Daylight Saving Time ends CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20231231_ucldqhu3apcoop6nflgm07k0c4@google.com DTSTART:20231231 DTEND:20240101 SUMMARY:New Year's Eve CLASS:PUBLIC CREATED:20210910T190032Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190032Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20231124_6g4mhi5vinqr5kpe2j2p09bumc@google.com DTSTART:20231124 DTEND:20231125 SUMMARY:Native American Heritage Day CLASS:PUBLIC CREATED:20210910T190032Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190032Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20211126_f7p24k9moujg3knjk3gqn0uqi0@google.com DTSTART:20211126 DTEND:20211127 SUMMARY:Native American Heritage Day CLASS:PUBLIC CREATED:20210910T190032Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190032Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210619_25s49v545th8e2g1l0s1age4q8@google.com DTSTART:20210619 DTEND:20210620 SUMMARY:Juneteenth CLASS:PUBLIC CREATED:20210910T190032Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190032Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210614_f0h43n320f06b6sl5km26hlgqo@google.com DTSTART:20210614 DTEND:20210615 SUMMARY:Flag Day CLASS:PUBLIC CREATED:20210910T190032Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190032Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20220301_jmeh9p188joa4s1f7udqmi7vcg@google.com DTSTART:20220301 DTEND:20220302 SUMMARY:First Day of Women's History Month CLASS:PUBLIC CREATED:20210910T190032Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190032Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20220915_6lba3248elmatsabr013c544ns@google.com DTSTART:20220915 DTEND:20220916 SUMMARY:First Day of Hispanic Heritage Month CLASS:PUBLIC CREATED:20210910T190032Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190032Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20220501_4nvga8ab05san3rljmk8gogmek@google.com DTSTART:20220501 DTEND:20220502 SUMMARY:First Day of Asian Pacific American Heritage Month CLASS:PUBLIC CREATED:20210910T190032Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190032Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20221010_h9m1g7t7hs8jujq8o3ub5fmapc@google.com DTSTART:20221010 DTEND:20221011 SUMMARY:Columbus Day CLASS:PUBLIC CREATED:20210910T190032Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190032Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20211011_je106i2o07ulmn1oqkhg3bpt20@google.com DTSTART:20211011 DTEND:20211012 SUMMARY:Columbus Day CLASS:PUBLIC CREATED:20210910T190032Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190032Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20221111_lv2fh3r4enk5a5742uidp649a0@google.com DTSTART:20221111 DTEND:20221112 SUMMARY:Veterans Day CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20230214_d59pfu6h8u8fq6s4sj2lnoo5gs@google.com DTSTART:20230214 DTEND:20230215 SUMMARY:Valentine's Day CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210317_t1pnk6k84rhic9mntdrsmqevo0@google.com DTSTART:20210317 DTEND:20210318 SUMMARY:St. Patrick's Day CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20230101_nhnms9gk6eamd27mbtcnbma84k@google.com DTSTART:20230101 DTEND:20230102 SUMMARY:New Year's Day CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210509_d805mmucb0345vqgfv7f4jdjc8@google.com DTSTART:20210509 DTEND:20210510 SUMMARY:Mother's Day CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210531_8em61chstgmokrolkqfg29fo64@google.com DTSTART:20210531 DTEND:20210601 SUMMARY:Memorial Day CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20230704_1ri8najbdk2o7uj1f8t8a1o6ks@google.com DTSTART:20230704 DTEND:20230705 SUMMARY:Independence Day CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210601_g4mul0focalhvsr7uncrdjsj2o@google.com DTSTART:20210601 DTEND:20210602 SUMMARY:First Day of LGBTQ+ Pride Month CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20230915_ph91tpp5mb73h9ihafa7cqnmb0@google.com DTSTART:20230915 DTEND:20230916 SUMMARY:First Day of Hispanic Heritage Month CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20221101_ek6rjrt8667gnvq9gm3h88ad98@google.com DTSTART:20221101 DTEND:20221102 SUMMARY:First Day of American Indian Heritage Month CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20230618_l6s2hg250270pbl2f5maovouno@google.com DTSTART:20230618 DTEND:20230619 SUMMARY:Father's Day CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20230410_d8pdh1sdo610bu7kbv15mrpvak@google.com DTSTART:20230410 DTEND:20230411 SUMMARY:Easter Monday CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20220418_0jkinfo1hgk7sane6d06im3m0c@google.com DTSTART:20220418 DTEND:20220419 SUMMARY:Easter Monday CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210314_lkgovfdmgtrqni8f31fis31c5c@google.com DTSTART:20210314 DTEND:20210315 SUMMARY:Daylight Saving Time starts CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20220505_gnpcourkrjsdqgd6l5p8m00kko@google.com DTSTART:20220505 DTEND:20220506 SUMMARY:Cinco de Mayo CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20220214_hahfjb9tapacrm71nkh6qs6egk@google.com DTSTART:20220214 DTEND:20220215 SUMMARY:Valentine's Day CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20220418_e5hpiqfqkmoe4d34p9d3fcio8s@google.com DTSTART:20220418 DTEND:20220419 SUMMARY:Tax Day CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210517_2eh4j1ptkb9tn4euk2u03voqdc@google.com DTSTART:20210517 DTEND:20210518 SUMMARY:Tax Day CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20211231_9cev8pie19qapks49npm04l9rc@google.com DTSTART:20211231 DTEND:20220101 SUMMARY:New Year's Eve CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20230529_f0vqs3ucctachbjeqeqbc9f3os@google.com DTSTART:20230529 DTEND:20230530 SUMMARY:Memorial Day CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20230116_jqnr1evsqf4psdprffkt3seirs@google.com DTSTART:20230116 DTEND:20230117 SUMMARY:Martin Luther King Jr. Day CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20220619_ik9o5ivf47d0lpgavip4bvg8t8@google.com DTSTART:20220619 DTEND:20220620 SUMMARY:Juneteenth CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20231009_t9s1rpkceqp8vjg2g2606tq8bc@google.com DTSTART:20231009 DTEND:20231010 SUMMARY:Indigenous Peoples' Day CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20211011_q11lvsl9kfvn0ub3dmu4mo8pig@google.com DTSTART:20211011 DTEND:20211012 SUMMARY:Indigenous Peoples' Day CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20231031_36klpu9coljcnm9nfgjth27al4@google.com DTSTART:20231031 DTEND:20231101 SUMMARY:Halloween CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20220619_7ctrqsm38ndrv6oopsqviti6rk@google.com DTSTART:20220619 DTEND:20220620 SUMMARY:Father's Day CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20221108_homlier8gatje3hh76f2ai5lio@google.com DTSTART:20221108 DTEND:20221109 SUMMARY:Election Day CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20231009_e8vvu38c3icj97ls28mtfidkp4@google.com DTSTART:20231009 DTEND:20231010 SUMMARY:Columbus Day CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20220221_9107h5bvj0daipoo5oq1fr1j7g@google.com DTSTART:20220221 DTEND:20220222 SUMMARY:Presidents' Day CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20230619_t1h5lk8nd1lbhfatjsib3lpuag@google.com DTSTART:20230619 DTEND:20230620 SUMMARY:Juneteenth CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20221010_e5gn60nvooi7b5n63lgt95svvc@google.com DTSTART:20221010 DTEND:20221011 SUMMARY:Indigenous Peoples' Day CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20221031_6glp9tsh22arlfd0ob8jbpneqs@google.com DTSTART:20221031 DTEND:20221101 SUMMARY:Halloween CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210301_lai8o9lsk6jcleuj0u8rshla2g@google.com DTSTART:20210301 DTEND:20210302 SUMMARY:First Day of Women's History Month CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20230201_jqf6jfp34j27r3htoj5djp243o@google.com DTSTART:20230201 DTEND:20230202 SUMMARY:First Day of Black History Month CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20220201_mu85q94gmfjqkf7je57t4koff0@google.com DTSTART:20220201 DTEND:20220202 SUMMARY:First Day of Black History Month CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20231101_tvl7hiji8jipl7hrutr4h62v5o@google.com DTSTART:20231101 DTEND:20231102 SUMMARY:First Day of American Indian Heritage Month CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210620_kvil948slg8fre7sp0udqaske0@google.com DTSTART:20210620 DTEND:20210621 SUMMARY:Father's Day CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20230409_75pm8qfnrbseuaga8d13e1glo4@google.com DTSTART:20230409 DTEND:20230410 SUMMARY:Easter Sunday CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20230312_lt6srv5ushtc031pd4s5t6ri7k@google.com DTSTART:20230312 DTEND:20230313 SUMMARY:Daylight Saving Time starts CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20220313_4k6mqpjvh52h82uasjatksbtpk@google.com DTSTART:20220313 DTEND:20220314 SUMMARY:Daylight Saving Time starts CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20221106_lgt8p48405gsmojtl6jcoenne0@google.com DTSTART:20221106 DTEND:20221107 SUMMARY:Daylight Saving Time ends CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20230505_tl426lafthi6vvnjlms6b0u0n8@google.com DTSTART:20230505 DTEND:20230506 SUMMARY:Cinco de Mayo CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210505_dg1j991u2tog58jf2omo6ueu44@google.com DTSTART:20210505 DTEND:20210506 SUMMARY:Cinco de Mayo CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20221224_mr7j751ra55nn0kd1lf4objb9g@google.com DTSTART:20221224 DTEND:20221225 SUMMARY:Christmas Eve CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20221225_j9ti48so951iffnrvplkvirvmg@google.com DTSTART:20221225 DTEND:20221226 SUMMARY:Christmas Day CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20231123_6b64ndbva1c6ptufj345j4opno@google.com DTSTART:20231123 DTEND:20231124 SUMMARY:Thanksgiving Day CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20230317_mam3qbsfcdm0jhm86rme5mfbos@google.com DTSTART:20230317 DTEND:20230318 SUMMARY:St. Patrick's Day CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20230220_icou39sbkm37nn1nrktk0ne0k0@google.com DTSTART:20230220 DTEND:20230221 SUMMARY:Presidents' Day CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210215_bjeb700bsa2s8f3llppic71ips@google.com DTSTART:20210215 DTEND:20210216 SUMMARY:Presidents' Day CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20220101_s1t4egh3li0g082fqkuvevmkq4@google.com DTSTART:20220101 DTEND:20220102 SUMMARY:New Year's Day CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20230904_sv1h93jbkec423gmvvebn3eblk@google.com DTSTART:20230904 DTEND:20230905 SUMMARY:Labor Day CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210906_tp5ib7il1bg4990u7cjncaelk8@google.com DTSTART:20210906 DTEND:20210907 SUMMARY:Labor Day CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210120_vtpup7q2um8qbtqccvosgkvmgs@google.com DTSTART:20210120 DTEND:20210121 SUMMARY:Inauguration Day (regional holiday) CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Public holiday in District of Columbia\, Maryland\, Virginia LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20230614_jntb38u1dq4oedjihsmvu0auj8@google.com DTSTART:20230614 DTEND:20230615 SUMMARY:Flag Day CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20220614_afb0e5hutl5v33gf1a3gvqg070@google.com DTSTART:20220614 DTEND:20220615 SUMMARY:Flag Day CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20230301_6555jab2tqmq1ip9mikifjpbp0@google.com DTSTART:20230301 DTEND:20230302 SUMMARY:First Day of Women's History Month CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20220601_i22r3so7ahvvng92aakskg64o4@google.com DTSTART:20220601 DTEND:20220602 SUMMARY:First Day of LGBTQ+ Pride Month CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210501_0ss4p7f8jhdrr35och32c0n6n8@google.com DTSTART:20210501 DTEND:20210502 SUMMARY:First Day of Asian Pacific American Heritage Month CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20231224_84424vvnc1o6o6ranpihu0b33k@google.com DTSTART:20231224 DTEND:20231225 SUMMARY:Christmas Eve CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20211224_vu488ou57f09b0nqdmlat6mork@google.com DTSTART:20211224 DTEND:20211225 SUMMARY:Christmas Eve CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20231111_ggtdplapook243vpjat2er758c@google.com DTSTART:20231111 DTEND:20231112 SUMMARY:Veterans Day CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20211111_dpg443k4rllrj1j4nuicsaujs0@google.com DTSTART:20211111 DTEND:20211112 SUMMARY:Veterans Day CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210214_reo046tof5lk55gc5kkrlkj778@google.com DTSTART:20210214 DTEND:20210215 SUMMARY:Valentine's Day CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20221124_q2bltm9681722h5dm8b4nksen4@google.com DTSTART:20221124 DTEND:20221125 SUMMARY:Thanksgiving Day CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20221231_sis3hnml9ikf19e59a6etn9qug@google.com DTSTART:20221231 DTEND:20230101 SUMMARY:New Year's Eve CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210118_1po1bsgspnatou2qf9gahqc7rk@google.com DTSTART:20210118 DTEND:20210119 SUMMARY:Martin Luther King Jr. Day CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20220905_9qfkiuho50ingbggv94h4svbis@google.com DTSTART:20220905 DTEND:20220906 SUMMARY:Labor Day CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210704_rkahlg7dv9hhgsa4hmjtr7ag8c@google.com DTSTART:20210704 DTEND:20210705 SUMMARY:Independence Day CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210201_0j09arjnv4r8gm0oldpkgua31s@google.com DTSTART:20210201 DTEND:20210202 SUMMARY:First Day of Black History Month CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20211101_stnvv736fobeq1tpsqjc9eoke0@google.com DTSTART:20211101 DTEND:20211102 SUMMARY:First Day of American Indian Heritage Month CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20220417_3s7sr1qa2d9o9oe5cbgd3b6ju0@google.com DTSTART:20220417 DTEND:20220418 SUMMARY:Easter Sunday CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20210405_6gqtfbnqna8nc2uhejk6g1jpjo@google.com DTSTART:20210405 DTEND:20210406 SUMMARY:Easter Monday CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Settings > Holidays in United States LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20220730T150314Z UID:20231225_ep50rip4a58phj35sevloms2hg@google.com DTSTART:20231225 DTEND:20231226 SUMMARY:Christmas Day CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT END:VCALENDAR ''' # --- # name: test_parse[recurring_event] ''' BEGIN:VCALENDAR CALSCALE:GREGORIAN METHOD:PUBLISH PRODID:-//Google Inc//Google Calendar 70.9054//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20250504T150136Z UID:v8rukz8ier7ijmn@google.com DTSTART:20250505 DTEND:20250506 SUMMARY:Some Event CREATED:20250504T145610Z LAST-MODIFIED:20250504T145729Z RRULE:FREQ=DAILY SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTAMP:20250504T150136Z UID:v8rukz8ier7ijmn@google.com DTSTART:20250507 DTEND:20250508 SUMMARY:Some Modified Event CREATED:20250504T145610Z LAST-MODIFIED:20250504T145738Z RECURRENCE-ID:20250507 SEQUENCE:0 STATUS:CONFIRMED TRANSP:TRANSPARENT END:VEVENT END:VCALENDAR ''' # --- # name: test_parse[store_edit_bugs] ''' BEGIN:VCALENDAR CALSCALE:GREGORIAN METHOD:PUBLISH PRODID:-//homeassistant.io//local_calendar 1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20210805T163353Z UID:HX7KXxJM9ZyvyH8D00BMVP-K9drT@proton.me DTSTART;TZID=Europe/Amsterdam:20210904T110000 DTEND;TZID=Europe/Amsterdam:20210904T120000 SUMMARY:My Event DESCRIPTION:Example RRULE:FREQ=WEEKLY SEQUENCE:0 STATUS:CONFIRMED END:VEVENT END:VCALENDAR ''' # --- allenporter-ical-fe8800b/tests/examples/test_ics_examples.py000066400000000000000000000021331510550726100244330ustar00rootroot00000000000000"""Test that parses .ics files from known good repositories.""" from pathlib import Path from itertools import islice import pytest from syrupy import SnapshotAssertion from ical.calendar_stream import IcsCalendarStream TEST_DIR = Path("tests/examples") TEST_FILES = sorted(list(TEST_DIR.glob("testdata/*.ics"))) TEST_IDS = [x.stem for x in TEST_FILES] @pytest.mark.parametrize("filename", TEST_FILES, ids=TEST_IDS) def test_parse(filename: Path, snapshot: SnapshotAssertion) -> None: """Test to read golden files and verify they are parsed.""" with filename.open() as ics_file: calendar = IcsCalendarStream.calendar_from_ics(ics_file.read()) assert IcsCalendarStream.calendar_to_ics(calendar) == snapshot @pytest.mark.parametrize("filename", TEST_FILES, ids=TEST_IDS) def test_iterate_events(filename: Path, snapshot: SnapshotAssertion) -> None: """Test to read golden files and verify they are parsed.""" with filename.open() as ics_file: calendar = IcsCalendarStream.calendar_from_ics(ics_file.read()) assert list(islice(iter(calendar.timeline), 5)) == snapshot allenporter-ical-fe8800b/tests/examples/testdata/000077500000000000000000000000001510550726100221605ustar00rootroot00000000000000allenporter-ical-fe8800b/tests/examples/testdata/apple_ical.ics000066400000000000000000000115451510550726100247570ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//caldav.icloud.com//CALDAVJ 2514B607//EN X-WR-CALNAME:Home X-APPLE-CALENDAR-COLOR:#ff2d55 BEGIN:VEVENT CREATED:20220925T061541Z UID:001BE545-52F9-4099-ACFC-A14FF63C4701 DTEND;TZID=America/Los_Angeles:20220927T100000 SUMMARY:New Event LAST-MODIFIED:20220925T061541Z DTSTAMP:20220925T191543Z DTSTART;TZID=America/Los_Angeles:20220927T090000 SEQUENCE:1 END:VEVENT BEGIN:VEVENT DTSTAMP:20231015T180900Z SUMMARY:Multi-day event TZID:America/Los_Angeles SEQUENCE:0 UID:020C71BB-A981-4520-83E9-98E488A60863 CREATED:20231015T180900Z DTSTART;VALUE=DATE:20231011 DTEND;VALUE=DATE:20231013 END:VEVENT BEGIN:VEVENT DTSTAMP:20231015T180532Z SUMMARY:Multi-day event TZID:America/Los_Angeles SEQUENCE:0 UID:3B4E9E16-8D79-422F-B48F-888861099B5B CREATED:20231015T180532Z DTSTART;VALUE=DATE:20231015 DTEND;VALUE=DATE:20231018 END:VEVENT BEGIN:VEVENT CREATED:20220924T152551Z UID:543E8A23-5A51-4489-8117-55241E8A1E30 DTEND;TZID=America/Los_Angeles:20220920T110000 SUMMARY:Example LAST-MODIFIED:20220924T152623Z DTSTAMP:20220924T152626Z DTSTART;TZID=America/Los_Angeles:20220920T090000 SEQUENCE:1 DESCRIPTION:Test description END:VEVENT BEGIN:VEVENT CREATED:20220924T170609Z UID:6D0A3855-9577-40D3-AE87-9624657C7561 RRULE:FREQ=DAILY;INTERVAL=1 DTEND;TZID=America/Los_Angeles:20220926T100000 SUMMARY:Daily LAST-MODIFIED:20220925T191650Z DTSTAMP:20220924T170617Z DTSTART;TZID=America/Los_Angeles:20220926T090000 SEQUENCE:1 END:VEVENT BEGIN:VEVENT CREATED:20220925T191543Z UID:A409C8CF-31E9-4234-BE8A-6CE2B6BB875A DTEND;TZID=America/Los_Angeles:20220912T100000 SUMMARY:New Event LAST-MODIFIED:20220925T191543Z DTSTAMP:20220925T191545Z DTSTART;TZID=America/Los_Angeles:20220912T090000 SEQUENCE:1 END:VEVENT BEGIN:VEVENT CREATED:20220925T191559Z UID:E53B06A1-9F72-41D9-9446-68E335D2D4F4 RRULE:FREQ=DAILY;UNTIL=20220926T155959Z;INTERVAL=1 DTEND;TZID=America/Los_Angeles:20220913T100000 SUMMARY:Daily LAST-MODIFIED:20220924T170616Z DTSTAMP:20220925T192230Z DTSTART;TZID=America/Los_Angeles:20220913T090000 SEQUENCE:1 END:VEVENT BEGIN:VEVENT CREATED:20220924T162621Z UID:F17B5DBD-1915-4654-90A5-1D068739A75F DTEND;TZID=America/Los_Angeles:20220922T100000 SUMMARY:bar LAST-MODIFIED:20220924T162627Z DTSTAMP:20220924T162627Z DTSTART;TZID=America/Los_Angeles:20220922T090000 SEQUENCE:1 DESCRIPTION:foo END:VEVENT BEGIN:VTIMEZONE TZID:America/Los_Angeles X-LIC-LOCATION:America/Los_Angeles BEGIN:STANDARD DTSTART:18831118T120702 RDATE:18831118T120702 TZNAME:PST TZOFFSETFROM:-075258 TZOFFSETTO:-0800 END:STANDARD BEGIN:DAYLIGHT DTSTART:19180331T020000 RRULE:FREQ=YEARLY;UNTIL=19190330T100000Z;BYMONTH=3;BYDAY=-1SU TZNAME:PDT TZOFFSETFROM:-0800 TZOFFSETTO:-0700 END:DAYLIGHT BEGIN:STANDARD DTSTART:19181027T020000 RRULE:FREQ=YEARLY;UNTIL=19191026T090000Z;BYMONTH=10;BYDAY=-1SU TZNAME:PST TZOFFSETFROM:-0700 TZOFFSETTO:-0800 END:STANDARD BEGIN:DAYLIGHT DTSTART:19420209T020000 RDATE:19420209T020000 TZNAME:PWT TZOFFSETFROM:-0800 TZOFFSETTO:-0700 END:DAYLIGHT BEGIN:DAYLIGHT DTSTART:19450814T160000 RDATE:19450814T160000 TZNAME:PPT TZOFFSETFROM:-0700 TZOFFSETTO:-0700 END:DAYLIGHT BEGIN:STANDARD DTSTART:19450930T020000 RDATE:19450930T020000 RDATE:19490101T020000 TZNAME:PST TZOFFSETFROM:-0700 TZOFFSETTO:-0800 END:STANDARD BEGIN:STANDARD DTSTART:19460101T000000 RDATE:19460101T000000 RDATE:19670101T000000 TZNAME:PST TZOFFSETFROM:-0800 TZOFFSETTO:-0800 END:STANDARD BEGIN:DAYLIGHT DTSTART:19480314T020100 RDATE:19480314T020100 RDATE:19740106T020000 RDATE:19750223T020000 TZNAME:PDT TZOFFSETFROM:-0800 TZOFFSETTO:-0700 END:DAYLIGHT BEGIN:DAYLIGHT DTSTART:19500430T010000 RRULE:FREQ=YEARLY;UNTIL=19660424T090000Z;BYMONTH=4;BYDAY=-1SU TZNAME:PDT TZOFFSETFROM:-0800 TZOFFSETTO:-0700 END:DAYLIGHT BEGIN:STANDARD DTSTART:19500924T020000 RRULE:FREQ=YEARLY;UNTIL=19610924T090000Z;BYMONTH=9;BYDAY=-1SU TZNAME:PST TZOFFSETFROM:-0700 TZOFFSETTO:-0800 END:STANDARD BEGIN:STANDARD DTSTART:19621028T020000 RRULE:FREQ=YEARLY;UNTIL=19661030T090000Z;BYMONTH=10;BYDAY=-1SU TZNAME:PST TZOFFSETFROM:-0700 TZOFFSETTO:-0800 END:STANDARD BEGIN:DAYLIGHT DTSTART:19670430T020000 RRULE:FREQ=YEARLY;UNTIL=19730429T100000Z;BYMONTH=4;BYDAY=-1SU TZNAME:PDT TZOFFSETFROM:-0800 TZOFFSETTO:-0700 END:DAYLIGHT BEGIN:STANDARD DTSTART:19671029T020000 RRULE:FREQ=YEARLY;UNTIL=20061029T090000Z;BYMONTH=10;BYDAY=-1SU TZNAME:PST TZOFFSETFROM:-0700 TZOFFSETTO:-0800 END:STANDARD BEGIN:DAYLIGHT DTSTART:19760425T020000 RRULE:FREQ=YEARLY;UNTIL=19860427T100000Z;BYMONTH=4;BYDAY=-1SU TZNAME:PDT TZOFFSETFROM:-0800 TZOFFSETTO:-0700 END:DAYLIGHT BEGIN:DAYLIGHT DTSTART:19870405T020000 RRULE:FREQ=YEARLY;UNTIL=20060402T100000Z;BYMONTH=4;BYDAY=1SU TZNAME:PDT TZOFFSETFROM:-0800 TZOFFSETTO:-0700 END:DAYLIGHT BEGIN:DAYLIGHT DTSTART:20070311T020000 RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU TZNAME:PDT TZOFFSETFROM:-0800 TZOFFSETTO:-0700 END:DAYLIGHT BEGIN:STANDARD DTSTART:20071104T020000 RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU TZNAME:PST TZOFFSETFROM:-0700 TZOFFSETTO:-0800 END:STANDARD END:VTIMEZONE END:VCALENDAR allenporter-ical-fe8800b/tests/examples/testdata/extended_values.ics000066400000000000000000000005171510550726100260420ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//github.com/allenporter/ical//8.2.0//EN VERSION:2.0 BEGIN:VTODO UID:20070313T123432Z-456553@example.com DTSTAMP:20250313T123432Z DUE;VALUE=DATE:20070501 SUMMARY:Submit Quebec Income Tax Return for 2006 CLASS:HERPADERPA CATEGORIES:FAMILY,FINANCE X-HER-PADERPA:foobar STATUS:NEEDS-ACTION END:VTODO END:VCALENDAR allenporter-ical-fe8800b/tests/examples/testdata/google_calendar_invalid_offset.ics000066400000000000000000000020351510550726100310410ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//Google Inc//Google Calendar 70.9054//EN VERSION:2.0 CALSCALE:GREGORIAN METHOD:PUBLISH X-WR-CALNAME:Compromissos X-WR-TIMEZONE:Etc/GMT BEGIN:VTIMEZONE TZID:America/Sao_Paulo X-LIC-LOCATION:America/Sao_Paulo BEGIN:STANDARD TZOFFSETFROM:-0300 TZOFFSETTO:-0300 TZNAME:GMT-3 DTSTART:19700101T000000 END:STANDARD END:VTIMEZONE BEGIN:VTIMEZONE TZID:America/Argentina/Buenos_Aires X-LIC-LOCATION:America/Argentina/Buenos_Aires BEGIN:STANDARD TZOFFSETFROM:-0300 TZOFFSETTO:-0300 TZNAME:GMT-3 DTSTART:19700101T000000 END:STANDARD END:VTIMEZONE BEGIN:VEVENT DTSTART;TZID=America/Argentina/Buenos_Aires:20030203T170000 DTEND;TZID=America/Argentina/Buenos_Aires:20030203T190000 RRULE:FREQ=DAILY;UNTIL=20030213T160000Z EXDATE;VALUE=DATE:20030208 EXDATE;VALUE=DATE:20030209 DTSTAMP:20250511T233945Z UID:KOrganizer-854889822.731//199594j26139acq39nj78tbhac@group.calendar.goo gle.com CLASS:PUBLIC CREATED:20060315T210421Z LAST-MODIFIED:20230220T034045Z SEQUENCE:3 STATUS:TENTATIVE SUMMARY:Event Summary TRANSP:OPAQUE END:VEVENT END:VCALENDAR allenporter-ical-fe8800b/tests/examples/testdata/google_calendar_public_holidays.ics000066400000000000000000001250611510550726100312240ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//Google Inc//Google Calendar 70.9054//EN VERSION:2.0 CALSCALE:GREGORIAN METHOD:PUBLISH X-WR-CALNAME:Holidays in United States X-WR-TIMEZONE:UTC X-WR-CALDESC:Holidays and Observances in United States BEGIN:VEVENT DTSTART;VALUE=DATE:20231110 DTEND;VALUE=DATE:20231111 DTSTAMP:20220730T150314Z UID:20231110_rhv0a9kca8mmmdo4ei84ubu46s@google.com CLASS:PUBLIC CREATED:20220126T031327Z DESCRIPTION:Public holiday LAST-MODIFIED:20220126T031327Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Veterans Day (substitute) TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20230102 DTEND;VALUE=DATE:20230103 DTSTAMP:20220730T150314Z UID:20230102_qn82d48cimkdg6jtog71ssphi8@google.com CLASS:PUBLIC CREATED:20220126T031327Z DESCRIPTION:Public holiday LAST-MODIFIED:20220126T031327Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:New Year's Day (substitute) TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20211231 DTEND;VALUE=DATE:20220101 DTSTAMP:20220730T150314Z UID:20211231_pjp0d08vkhqsv10an7hs3u2a9c@google.com CLASS:PUBLIC CREATED:20220126T031327Z DESCRIPTION:Public holiday LAST-MODIFIED:20220126T031327Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:New Year's Day (substitute) TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20220620 DTEND;VALUE=DATE:20220621 DTSTAMP:20220730T150314Z UID:20220620_2ggfpo9bnaj019jh2jn4m15934@google.com CLASS:PUBLIC CREATED:20220126T031327Z DESCRIPTION:Public holiday LAST-MODIFIED:20220126T031327Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Juneteenth (substitute) TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210618 DTEND;VALUE=DATE:20210619 DTSTAMP:20220730T150314Z UID:20210618_vg7iiogg0qtjhpp3mrs1oeq48s@google.com CLASS:PUBLIC CREATED:20220126T031327Z DESCRIPTION:Public holiday LAST-MODIFIED:20220126T031327Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Juneteenth (substitute) TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210705 DTEND;VALUE=DATE:20210706 DTSTAMP:20220730T150314Z UID:20210705_2vtd20oge5alkavuvoogkf021k@google.com CLASS:PUBLIC CREATED:20220126T031327Z DESCRIPTION:Public holiday LAST-MODIFIED:20220126T031327Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Independence Day (substitute) TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20221226 DTEND;VALUE=DATE:20221227 DTSTAMP:20220730T150314Z UID:20221226_j7qpbluailblij0c1i09uuia8o@google.com CLASS:PUBLIC CREATED:20220126T031327Z DESCRIPTION:Public holiday LAST-MODIFIED:20220126T031327Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Christmas Day (substitute) TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20211224 DTEND;VALUE=DATE:20211225 DTSTAMP:20220730T150314Z UID:20211224_9fvbilr0017a8t853k466rquoc@google.com CLASS:PUBLIC CREATED:20220126T031327Z DESCRIPTION:Public holiday LAST-MODIFIED:20220126T031327Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Christmas Day (substitute) TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20230514 DTEND;VALUE=DATE:20230515 DTSTAMP:20220730T150314Z UID:20230514_4tku8pd1d4vqvrompkbqt51bps@google.com CLASS:PUBLIC CREATED:20210910T190037Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190037Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Mother's Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20220117 DTEND;VALUE=DATE:20220118 DTSTAMP:20220730T150314Z UID:20220117_a8pam0i5kuqajqn3gih7bsdfn4@google.com CLASS:PUBLIC CREATED:20210910T190037Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190037Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Martin Luther King Jr. Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20220704 DTEND;VALUE=DATE:20220705 DTSTAMP:20220730T150314Z UID:20220704_7mjropb9h0lmq9902t7jhanq94@google.com CLASS:PUBLIC CREATED:20210910T190037Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190037Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Independence Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20211031 DTEND;VALUE=DATE:20211101 DTSTAMP:20220730T150314Z UID:20211031_hb7ms1o1dfan5l41e7npt91sks@google.com CLASS:PUBLIC CREATED:20210910T190037Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190037Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Halloween TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210915 DTEND;VALUE=DATE:20210916 DTSTAMP:20220730T150314Z UID:20210915_mki3pbd3g88fds6gfomm419oqg@google.com CLASS:PUBLIC CREATED:20210910T190037Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190037Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:First Day of Hispanic Heritage Month TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20211102 DTEND;VALUE=DATE:20211103 DTSTAMP:20220730T150314Z UID:20211102_00haicklogosrcbanisb2cudd4@google.com CLASS:PUBLIC CREATED:20210910T190037Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190037Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Election Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20211107 DTEND;VALUE=DATE:20211108 DTSTAMP:20220730T150314Z UID:20211107_n3rolco9cpvq95g7nqvlqsf0l0@google.com CLASS:PUBLIC CREATED:20210910T190037Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190037Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Daylight Saving Time ends TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20211225 DTEND;VALUE=DATE:20211226 DTSTAMP:20220730T150314Z UID:20211225_56c03granps4dfloccthnu4288@google.com CLASS:PUBLIC CREATED:20210910T190037Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190037Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Christmas Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20211125 DTEND;VALUE=DATE:20211126 DTSTAMP:20220730T150314Z UID:20211125_342uhr2rqf1qtvaqlg7nf58l94@google.com CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Thanksgiving Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20230418 DTEND;VALUE=DATE:20230419 DTSTAMP:20220730T150314Z UID:20230418_fdjpuvbba1bjjqobbvpsf062fs@google.com CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Tax Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20220317 DTEND;VALUE=DATE:20220318 DTSTAMP:20220730T150314Z UID:20220317_mnqa8ubhi14rjesehu00kn46fg@google.com CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:St. Patrick's Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210101 DTEND;VALUE=DATE:20210102 DTSTAMP:20220730T150314Z UID:20210101_fokaslga04sgnfruusv1tomhs4@google.com CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:New Year's Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20221125 DTEND;VALUE=DATE:20221126 DTSTAMP:20220730T150314Z UID:20221125_5fsp6cfd26fr9m158ot932b994@google.com CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Native American Heritage Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20220508 DTEND;VALUE=DATE:20220509 DTSTAMP:20220730T150314Z UID:20220508_5j9tt75njq2mc9s84ktghv5uo4@google.com CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Mother's Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20220530 DTEND;VALUE=DATE:20220531 DTSTAMP:20220730T150314Z UID:20220530_mubrcfb1ema52icfs7jvo52i8o@google.com CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Memorial Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20230601 DTEND;VALUE=DATE:20230602 DTSTAMP:20220730T150314Z UID:20230601_2vhknhkbc0vkcd1ks1orb0qtq4@google.com CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:First Day of LGBTQ+ Pride Month TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20230501 DTEND;VALUE=DATE:20230502 DTSTAMP:20220730T150314Z UID:20230501_k826tj3euiqb7bd2bknnvrduvs@google.com CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:First Day of Asian Pacific American Heritage Month TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20231107 DTEND;VALUE=DATE:20231108 DTSTAMP:20220730T150314Z UID:20231107_fc8f1530s41qftcnc9c75jccok@google.com CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Election Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210404 DTEND;VALUE=DATE:20210405 DTSTAMP:20220730T150314Z UID:20210404_u6jdgtbhpnu5uff5es0g2u55p8@google.com CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Easter Sunday TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20231105 DTEND;VALUE=DATE:20231106 DTSTAMP:20220730T150314Z UID:20231105_drikm9rqmroskv6c07ug7t5l8o@google.com CLASS:PUBLIC CREATED:20210910T190034Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190034Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Daylight Saving Time ends TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20231231 DTEND;VALUE=DATE:20240101 DTSTAMP:20220730T150314Z UID:20231231_ucldqhu3apcoop6nflgm07k0c4@google.com CLASS:PUBLIC CREATED:20210910T190032Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190032Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:New Year's Eve TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20231124 DTEND;VALUE=DATE:20231125 DTSTAMP:20220730T150314Z UID:20231124_6g4mhi5vinqr5kpe2j2p09bumc@google.com CLASS:PUBLIC CREATED:20210910T190032Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190032Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Native American Heritage Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20211126 DTEND;VALUE=DATE:20211127 DTSTAMP:20220730T150314Z UID:20211126_f7p24k9moujg3knjk3gqn0uqi0@google.com CLASS:PUBLIC CREATED:20210910T190032Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190032Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Native American Heritage Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210619 DTEND;VALUE=DATE:20210620 DTSTAMP:20220730T150314Z UID:20210619_25s49v545th8e2g1l0s1age4q8@google.com CLASS:PUBLIC CREATED:20210910T190032Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190032Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Juneteenth TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210614 DTEND;VALUE=DATE:20210615 DTSTAMP:20220730T150314Z UID:20210614_f0h43n320f06b6sl5km26hlgqo@google.com CLASS:PUBLIC CREATED:20210910T190032Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190032Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Flag Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20220301 DTEND;VALUE=DATE:20220302 DTSTAMP:20220730T150314Z UID:20220301_jmeh9p188joa4s1f7udqmi7vcg@google.com CLASS:PUBLIC CREATED:20210910T190032Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190032Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:First Day of Women's History Month TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20220915 DTEND;VALUE=DATE:20220916 DTSTAMP:20220730T150314Z UID:20220915_6lba3248elmatsabr013c544ns@google.com CLASS:PUBLIC CREATED:20210910T190032Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190032Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:First Day of Hispanic Heritage Month TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20220501 DTEND;VALUE=DATE:20220502 DTSTAMP:20220730T150314Z UID:20220501_4nvga8ab05san3rljmk8gogmek@google.com CLASS:PUBLIC CREATED:20210910T190032Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190032Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:First Day of Asian Pacific American Heritage Month TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20221010 DTEND;VALUE=DATE:20221011 DTSTAMP:20220730T150314Z UID:20221010_h9m1g7t7hs8jujq8o3ub5fmapc@google.com CLASS:PUBLIC CREATED:20210910T190032Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190032Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Columbus Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20211011 DTEND;VALUE=DATE:20211012 DTSTAMP:20220730T150314Z UID:20211011_je106i2o07ulmn1oqkhg3bpt20@google.com CLASS:PUBLIC CREATED:20210910T190032Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190032Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Columbus Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20221111 DTEND;VALUE=DATE:20221112 DTSTAMP:20220730T150314Z UID:20221111_lv2fh3r4enk5a5742uidp649a0@google.com CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Veterans Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20230214 DTEND;VALUE=DATE:20230215 DTSTAMP:20220730T150314Z UID:20230214_d59pfu6h8u8fq6s4sj2lnoo5gs@google.com CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Valentine's Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210317 DTEND;VALUE=DATE:20210318 DTSTAMP:20220730T150314Z UID:20210317_t1pnk6k84rhic9mntdrsmqevo0@google.com CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:St. Patrick's Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20230101 DTEND;VALUE=DATE:20230102 DTSTAMP:20220730T150314Z UID:20230101_nhnms9gk6eamd27mbtcnbma84k@google.com CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:New Year's Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210509 DTEND;VALUE=DATE:20210510 DTSTAMP:20220730T150314Z UID:20210509_d805mmucb0345vqgfv7f4jdjc8@google.com CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Mother's Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210531 DTEND;VALUE=DATE:20210601 DTSTAMP:20220730T150314Z UID:20210531_8em61chstgmokrolkqfg29fo64@google.com CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Memorial Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20230704 DTEND;VALUE=DATE:20230705 DTSTAMP:20220730T150314Z UID:20230704_1ri8najbdk2o7uj1f8t8a1o6ks@google.com CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Independence Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210601 DTEND;VALUE=DATE:20210602 DTSTAMP:20220730T150314Z UID:20210601_g4mul0focalhvsr7uncrdjsj2o@google.com CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:First Day of LGBTQ+ Pride Month TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20230915 DTEND;VALUE=DATE:20230916 DTSTAMP:20220730T150314Z UID:20230915_ph91tpp5mb73h9ihafa7cqnmb0@google.com CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:First Day of Hispanic Heritage Month TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20221101 DTEND;VALUE=DATE:20221102 DTSTAMP:20220730T150314Z UID:20221101_ek6rjrt8667gnvq9gm3h88ad98@google.com CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:First Day of American Indian Heritage Month TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20230618 DTEND;VALUE=DATE:20230619 DTSTAMP:20220730T150314Z UID:20230618_l6s2hg250270pbl2f5maovouno@google.com CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Father's Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20230410 DTEND;VALUE=DATE:20230411 DTSTAMP:20220730T150314Z UID:20230410_d8pdh1sdo610bu7kbv15mrpvak@google.com CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Easter Monday TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20220418 DTEND;VALUE=DATE:20220419 DTSTAMP:20220730T150314Z UID:20220418_0jkinfo1hgk7sane6d06im3m0c@google.com CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Easter Monday TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210314 DTEND;VALUE=DATE:20210315 DTSTAMP:20220730T150314Z UID:20210314_lkgovfdmgtrqni8f31fis31c5c@google.com CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Daylight Saving Time starts TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20220505 DTEND;VALUE=DATE:20220506 DTSTAMP:20220730T150314Z UID:20220505_gnpcourkrjsdqgd6l5p8m00kko@google.com CLASS:PUBLIC CREATED:20210910T190030Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190030Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Cinco de Mayo TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20220214 DTEND;VALUE=DATE:20220215 DTSTAMP:20220730T150314Z UID:20220214_hahfjb9tapacrm71nkh6qs6egk@google.com CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Valentine's Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20220418 DTEND;VALUE=DATE:20220419 DTSTAMP:20220730T150314Z UID:20220418_e5hpiqfqkmoe4d34p9d3fcio8s@google.com CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Tax Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210517 DTEND;VALUE=DATE:20210518 DTSTAMP:20220730T150314Z UID:20210517_2eh4j1ptkb9tn4euk2u03voqdc@google.com CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Tax Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20211231 DTEND;VALUE=DATE:20220101 DTSTAMP:20220730T150314Z UID:20211231_9cev8pie19qapks49npm04l9rc@google.com CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:New Year's Eve TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20230529 DTEND;VALUE=DATE:20230530 DTSTAMP:20220730T150314Z UID:20230529_f0vqs3ucctachbjeqeqbc9f3os@google.com CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Memorial Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20230116 DTEND;VALUE=DATE:20230117 DTSTAMP:20220730T150314Z UID:20230116_jqnr1evsqf4psdprffkt3seirs@google.com CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Martin Luther King Jr. Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20220619 DTEND;VALUE=DATE:20220620 DTSTAMP:20220730T150314Z UID:20220619_ik9o5ivf47d0lpgavip4bvg8t8@google.com CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Juneteenth TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20231009 DTEND;VALUE=DATE:20231010 DTSTAMP:20220730T150314Z UID:20231009_t9s1rpkceqp8vjg2g2606tq8bc@google.com CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Indigenous Peoples' Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20211011 DTEND;VALUE=DATE:20211012 DTSTAMP:20220730T150314Z UID:20211011_q11lvsl9kfvn0ub3dmu4mo8pig@google.com CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Indigenous Peoples' Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20231031 DTEND;VALUE=DATE:20231101 DTSTAMP:20220730T150314Z UID:20231031_36klpu9coljcnm9nfgjth27al4@google.com CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Halloween TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20220619 DTEND;VALUE=DATE:20220620 DTSTAMP:20220730T150314Z UID:20220619_7ctrqsm38ndrv6oopsqviti6rk@google.com CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Father's Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20221108 DTEND;VALUE=DATE:20221109 DTSTAMP:20220730T150314Z UID:20221108_homlier8gatje3hh76f2ai5lio@google.com CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Election Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20231009 DTEND;VALUE=DATE:20231010 DTSTAMP:20220730T150314Z UID:20231009_e8vvu38c3icj97ls28mtfidkp4@google.com CLASS:PUBLIC CREATED:20210910T190028Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190028Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Columbus Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20220221 DTEND;VALUE=DATE:20220222 DTSTAMP:20220730T150314Z UID:20220221_9107h5bvj0daipoo5oq1fr1j7g@google.com CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Presidents' Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20230619 DTEND;VALUE=DATE:20230620 DTSTAMP:20220730T150314Z UID:20230619_t1h5lk8nd1lbhfatjsib3lpuag@google.com CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Juneteenth TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20221010 DTEND;VALUE=DATE:20221011 DTSTAMP:20220730T150314Z UID:20221010_e5gn60nvooi7b5n63lgt95svvc@google.com CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Indigenous Peoples' Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20221031 DTEND;VALUE=DATE:20221101 DTSTAMP:20220730T150314Z UID:20221031_6glp9tsh22arlfd0ob8jbpneqs@google.com CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Halloween TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210301 DTEND;VALUE=DATE:20210302 DTSTAMP:20220730T150314Z UID:20210301_lai8o9lsk6jcleuj0u8rshla2g@google.com CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:First Day of Women's History Month TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20230201 DTEND;VALUE=DATE:20230202 DTSTAMP:20220730T150314Z UID:20230201_jqf6jfp34j27r3htoj5djp243o@google.com CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:First Day of Black History Month TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20220201 DTEND;VALUE=DATE:20220202 DTSTAMP:20220730T150314Z UID:20220201_mu85q94gmfjqkf7je57t4koff0@google.com CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:First Day of Black History Month TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20231101 DTEND;VALUE=DATE:20231102 DTSTAMP:20220730T150314Z UID:20231101_tvl7hiji8jipl7hrutr4h62v5o@google.com CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:First Day of American Indian Heritage Month TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210620 DTEND;VALUE=DATE:20210621 DTSTAMP:20220730T150314Z UID:20210620_kvil948slg8fre7sp0udqaske0@google.com CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Father's Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20230409 DTEND;VALUE=DATE:20230410 DTSTAMP:20220730T150314Z UID:20230409_75pm8qfnrbseuaga8d13e1glo4@google.com CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Easter Sunday TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20230312 DTEND;VALUE=DATE:20230313 DTSTAMP:20220730T150314Z UID:20230312_lt6srv5ushtc031pd4s5t6ri7k@google.com CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Daylight Saving Time starts TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20220313 DTEND;VALUE=DATE:20220314 DTSTAMP:20220730T150314Z UID:20220313_4k6mqpjvh52h82uasjatksbtpk@google.com CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Daylight Saving Time starts TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20221106 DTEND;VALUE=DATE:20221107 DTSTAMP:20220730T150314Z UID:20221106_lgt8p48405gsmojtl6jcoenne0@google.com CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Daylight Saving Time ends TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20230505 DTEND;VALUE=DATE:20230506 DTSTAMP:20220730T150314Z UID:20230505_tl426lafthi6vvnjlms6b0u0n8@google.com CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Cinco de Mayo TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210505 DTEND;VALUE=DATE:20210506 DTSTAMP:20220730T150314Z UID:20210505_dg1j991u2tog58jf2omo6ueu44@google.com CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Cinco de Mayo TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20221224 DTEND;VALUE=DATE:20221225 DTSTAMP:20220730T150314Z UID:20221224_mr7j751ra55nn0kd1lf4objb9g@google.com CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Christmas Eve TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20221225 DTEND;VALUE=DATE:20221226 DTSTAMP:20220730T150314Z UID:20221225_j9ti48so951iffnrvplkvirvmg@google.com CLASS:PUBLIC CREATED:20210910T190027Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190027Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Christmas Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20231123 DTEND;VALUE=DATE:20231124 DTSTAMP:20220730T150314Z UID:20231123_6b64ndbva1c6ptufj345j4opno@google.com CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Thanksgiving Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20230317 DTEND;VALUE=DATE:20230318 DTSTAMP:20220730T150314Z UID:20230317_mam3qbsfcdm0jhm86rme5mfbos@google.com CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:St. Patrick's Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20230220 DTEND;VALUE=DATE:20230221 DTSTAMP:20220730T150314Z UID:20230220_icou39sbkm37nn1nrktk0ne0k0@google.com CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Presidents' Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210215 DTEND;VALUE=DATE:20210216 DTSTAMP:20220730T150314Z UID:20210215_bjeb700bsa2s8f3llppic71ips@google.com CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Presidents' Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20220101 DTEND;VALUE=DATE:20220102 DTSTAMP:20220730T150314Z UID:20220101_s1t4egh3li0g082fqkuvevmkq4@google.com CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:New Year's Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20230904 DTEND;VALUE=DATE:20230905 DTSTAMP:20220730T150314Z UID:20230904_sv1h93jbkec423gmvvebn3eblk@google.com CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Labor Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210906 DTEND;VALUE=DATE:20210907 DTSTAMP:20220730T150314Z UID:20210906_tp5ib7il1bg4990u7cjncaelk8@google.com CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Labor Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210120 DTEND;VALUE=DATE:20210121 DTSTAMP:20220730T150314Z UID:20210120_vtpup7q2um8qbtqccvosgkvmgs@google.com CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Public holiday in District of Columbia\, Maryland\, Virginia LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Inauguration Day (regional holiday) TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20230614 DTEND;VALUE=DATE:20230615 DTSTAMP:20220730T150314Z UID:20230614_jntb38u1dq4oedjihsmvu0auj8@google.com CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Flag Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20220614 DTEND;VALUE=DATE:20220615 DTSTAMP:20220730T150314Z UID:20220614_afb0e5hutl5v33gf1a3gvqg070@google.com CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Flag Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20230301 DTEND;VALUE=DATE:20230302 DTSTAMP:20220730T150314Z UID:20230301_6555jab2tqmq1ip9mikifjpbp0@google.com CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:First Day of Women's History Month TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20220601 DTEND;VALUE=DATE:20220602 DTSTAMP:20220730T150314Z UID:20220601_i22r3so7ahvvng92aakskg64o4@google.com CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:First Day of LGBTQ+ Pride Month TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210501 DTEND;VALUE=DATE:20210502 DTSTAMP:20220730T150314Z UID:20210501_0ss4p7f8jhdrr35och32c0n6n8@google.com CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:First Day of Asian Pacific American Heritage Month TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20231224 DTEND;VALUE=DATE:20231225 DTSTAMP:20220730T150314Z UID:20231224_84424vvnc1o6o6ranpihu0b33k@google.com CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Christmas Eve TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20211224 DTEND;VALUE=DATE:20211225 DTSTAMP:20220730T150314Z UID:20211224_vu488ou57f09b0nqdmlat6mork@google.com CLASS:PUBLIC CREATED:20210910T190025Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190025Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Christmas Eve TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20231111 DTEND;VALUE=DATE:20231112 DTSTAMP:20220730T150314Z UID:20231111_ggtdplapook243vpjat2er758c@google.com CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Veterans Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20211111 DTEND;VALUE=DATE:20211112 DTSTAMP:20220730T150314Z UID:20211111_dpg443k4rllrj1j4nuicsaujs0@google.com CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Veterans Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210214 DTEND;VALUE=DATE:20210215 DTSTAMP:20220730T150314Z UID:20210214_reo046tof5lk55gc5kkrlkj778@google.com CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Valentine's Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20221124 DTEND;VALUE=DATE:20221125 DTSTAMP:20220730T150314Z UID:20221124_q2bltm9681722h5dm8b4nksen4@google.com CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Thanksgiving Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20221231 DTEND;VALUE=DATE:20230101 DTSTAMP:20220730T150314Z UID:20221231_sis3hnml9ikf19e59a6etn9qug@google.com CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:New Year's Eve TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210118 DTEND;VALUE=DATE:20210119 DTSTAMP:20220730T150314Z UID:20210118_1po1bsgspnatou2qf9gahqc7rk@google.com CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Martin Luther King Jr. Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20220905 DTEND;VALUE=DATE:20220906 DTSTAMP:20220730T150314Z UID:20220905_9qfkiuho50ingbggv94h4svbis@google.com CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Labor Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210704 DTEND;VALUE=DATE:20210705 DTSTAMP:20220730T150314Z UID:20210704_rkahlg7dv9hhgsa4hmjtr7ag8c@google.com CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Independence Day TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210201 DTEND;VALUE=DATE:20210202 DTSTAMP:20220730T150314Z UID:20210201_0j09arjnv4r8gm0oldpkgua31s@google.com CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:First Day of Black History Month TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20211101 DTEND;VALUE=DATE:20211102 DTSTAMP:20220730T150314Z UID:20211101_stnvv736fobeq1tpsqjc9eoke0@google.com CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:First Day of American Indian Heritage Month TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20220417 DTEND;VALUE=DATE:20220418 DTSTAMP:20220730T150314Z UID:20220417_3s7sr1qa2d9o9oe5cbgd3b6ju0@google.com CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Easter Sunday TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20210405 DTEND;VALUE=DATE:20210406 DTSTAMP:20220730T150314Z UID:20210405_6gqtfbnqna8nc2uhejk6g1jpjo@google.com CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Observance\nTo hide observances\, go to Google Calendar Setting s > Holidays in United States LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Easter Monday TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20231225 DTEND;VALUE=DATE:20231226 DTSTAMP:20220730T150314Z UID:20231225_ep50rip4a58phj35sevloms2hg@google.com CLASS:PUBLIC CREATED:20210910T190023Z DESCRIPTION:Public holiday LAST-MODIFIED:20210910T190023Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Christmas Day TRANSP:TRANSPARENT END:VEVENT END:VCALENDAR allenporter-ical-fe8800b/tests/examples/testdata/recurring_event.ics000066400000000000000000000014001510550726100260540ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//Google Inc//Google Calendar 70.9054//EN VERSION:2.0 CALSCALE:GREGORIAN METHOD:PUBLISH X-WR-CALNAME:Public Recurrence Test X-WR-TIMEZONE:America/Los_Angeles BEGIN:VEVENT DTSTART;VALUE=DATE:20250505 DTEND;VALUE=DATE:20250506 RRULE:FREQ=DAILY DTSTAMP:20250504T150136Z UID:v8rukz8ier7ijmn@google.com CREATED:20250504T145610Z LAST-MODIFIED:20250504T145729Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Some Event TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20250507 DTEND;VALUE=DATE:20250508 DTSTAMP:20250504T150136Z UID:v8rukz8ier7ijmn@google.com RECURRENCE-ID;VALUE=DATE:20250507 CREATED:20250504T145610Z LAST-MODIFIED:20250504T145738Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Some Modified Event TRANSP:TRANSPARENT END:VEVENT END:VCALENDAR allenporter-ical-fe8800b/tests/examples/testdata/store_edit_bugs.ics000066400000000000000000000006101510550726100260360ustar00rootroot00000000000000BEGIN:VCALENDAR CALSCALE:GREGORIAN METHOD:PUBLISH PRODID:-//homeassistant.io//local_calendar 1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20210805T163353Z UID:HX7KXxJM9ZyvyH8D00BMVP-K9drT@proton.me DTSTART;TZID=Europe/Amsterdam:20210904T110000 DTEND;TZID=Europe/Amsterdam:20210904T120000 SUMMARY:My Event DESCRIPTION:Example RRULE:FREQ=WEEKLY SEQUENCE:0 STATUS:CONFIRMED END:VEVENT END:VCALENDAR allenporter-ical-fe8800b/tests/parsing/000077500000000000000000000000001510550726100201745ustar00rootroot00000000000000allenporter-ical-fe8800b/tests/parsing/__snapshots__/000077500000000000000000000000001510550726100230125ustar00rootroot00000000000000allenporter-ical-fe8800b/tests/parsing/__snapshots__/test_component.ambr000066400000000000000000000750261510550726100267300ustar00rootroot00000000000000# serializer version: 1 # name: test_encode_contentlines[attendee] ''' BEGIN:VEVENT ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT:mailto: jsmith@example.com ATTENDEE;DELEGATED-TO="mailto:jdoe@example.com","mailto:jqpublic @example.com":mailto:jsmith@example.com END:VEVENT ''' # --- # name: test_encode_contentlines[comma] ''' BEGIN:VEVENT DESCRIPTION;ALTREP="cid:part1.0001@example.org":The Fall'98 Wild Wizards Conference - - Las Vegas\, NV\, USA END:VEVENT ''' # --- # name: test_encode_contentlines[emoji] ''' BEGIN:VCALENDAR VERSION:2.0 PRODID:-//hacksw/handcal//NONSGML v1.0//EN BEGIN:VEVENT UID:19970610T172345Z-AF23B2@example.com DTSTAMP:19970610T172345Z DTSTART:19971225 DTEND:19971226 SUMMARY:🎄 END:VEVENT END:VCALENDAR ''' # --- # name: test_encode_contentlines[fold] ''' BEGIN:VEVENT DESCRIPTION:This is a lo ng description that exists on a long line. END:VEVENT ''' # --- # name: test_encode_contentlines[icalendar_object] ''' BEGIN:VCALENDAR VERSION:2.0 PRODID:-//hacksw/handcal//NONSGML v1.0//EN BEGIN:VEVENT UID:19970610T172345Z-AF23B2@example.com DTSTAMP:19970610T172345Z DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:Bastille Day Party END:VEVENT END:VCALENDAR ''' # --- # name: test_encode_contentlines[languages] ''' BEGIN:VCALENDAR VERSION:2.0 PRODID:-//hacksw/handcal//NONSGML v1.0//EN BEGIN:VEVENT UID:19970610T172345Z-AF23B2@example.com DTSTAMP:19970610T172345Z DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:žmogus END:VEVENT BEGIN:VEVENT UID:19970610T172345Z-AF23B3@example.com DTSTAMP:19970610T172345Z DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:中文 END:VEVENT BEGIN:VEVENT UID:19970610T172345Z-AF23B4@example.com DTSTAMP:19970610T172345Z DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:кириллица END:VEVENT BEGIN:VEVENT UID:19970610T172345Z-AF23B5@example.com DTSTAMP:19970610T172345Z DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:Ελληνικά END:VEVENT BEGIN:VEVENT UID:19970610T172345Z-AF23B6@example.com DTSTAMP:19970610T172345Z DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:עִברִית END:VEVENT BEGIN:VEVENT UID:19970610T172345Z-AF23B7@example.com DTSTAMP:19970610T172345Z DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:日本語 END:VEVENT BEGIN:VEVENT UID:19970610T172345Z-AF23B8@example.com DTSTAMP:19970610T172345Z DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:한국어 END:VEVENT BEGIN:VEVENT UID:19970610T172345Z-AF23B9@example.com DTSTAMP:19970610T172345Z DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:ไทย END:VEVENT BEGIN:VEVENT UID:19970610T172345Z-AF23B10@example.com DTSTAMP:19970610T172345Z DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:देवनागरी END:VEVENT END:VCALENDAR ''' # --- # name: test_encode_contentlines[params] ''' BEGIN:VCALENDAR NAME;PARAM-NAME=PARAM-VALUE1:VALUE NAME;PARAM-NAME=PARAM+VALUE2:VALUE NAME;PARAM-NAME=PARAM VALUE3:VALUE NAME;PARAM-NAME="PARAM:VALUE4":VALUE DESCRIPTION: DESCRIPTION;TYPE=: END:VCALENDAR ''' # --- # name: test_encode_contentlines[params_empty] ''' BEGIN:VCALENDAR NAME;PARAM-NAME=:VALUE NAME;PARAM-NAME=,Value2,,VALUE4,VALUE5:VALUE END:VCALENDAR ''' # --- # name: test_encode_contentlines[params_quoted] ''' BEGIN:VCALENDAR NAME;PARAM-NAME=PARAM-VALUE:VALUE NAME;PARAM-NAME="PARAM:VALUE":VALUE END:VCALENDAR ''' # --- # name: test_encode_contentlines[rdate] ''' BEGIN:VCALENDAR RDATE;VALUE=DATE:19970304,19970504,19970704,19970904 END:VCALENDAR ''' # --- # name: test_encode_contentlines[unicode] ''' BEGIN:VCALENDAR PRODID:-//ABC Corporation//NONSGML My Product//EN VERSION:2.0 BEGIN:VTODO DTSTAMP:19980130T134500Z SEQUENCE:2 UID:uid4@example.com DUE:19980415T000000 STATUS:NEEDS-ACTION SUMMARY:Birthday BEGIN:VALARM ACTION:AUDIO TRIGGER:19980403T120000Z ATTACH;FILENAME=Fødselsdag_40.pdf:https://someurl.com REPEAT:4 DURATION:PT1H END:VALARM END:VTODO END:VCALENDAR ''' # --- # name: test_encode_contentlines[vcalendar_emoji] ''' BEGIN:VEVENT DTSTAMP:20221202T075310 UID:5deea302-7216-11ed-b1b6-48d2240d04ae DTSTART:20221202T085500 DTEND:20221202T090000 SUMMARY:🎄emojis! CREATED:20221202T075310 SEQUENCE:0 END:VEVENT ''' # --- # name: test_encode_contentlines[vevent] ''' BEGIN:VEVENT UID:19970901T130000Z-123401@example.com DTSTAMP:19970901T130000Z DTSTART:19970903T163000Z DTEND:19970903T190000Z SUMMARY:Annual Employee Review CLASS:PRIVATE CATEGORIES:BUSINESS,HUMAN RESOURCES END:VEVENT ''' # --- # name: test_encode_contentlines[vtodo] ''' BEGIN:VTODO UID:20070313T123432Z-456553@example.com DTSTAMP:20070313T123432Z DUE;VALUE=DATE:20070501 SUMMARY:Submit Quebec Income Tax Return for 2006 CLASS:CONFIDENTIAL CATEGORIES:FAMILY,FINANCE STATUS:NEEDS-ACTION END:VTODO ''' # --- # name: test_encode_contentlines[x-name] ''' BEGIN:VEVENT X-DESCRIPTION:This is a description X-ICAL-DESCRIPTION:This is a description X-ICAL-1:This is another x-name END:VEVENT ''' # --- # name: test_invalid_contentlines[invalid] tuple( 'Calendar contents are not valid ICS format, see the detailed_error for more information', "Invalid property line, expected (';', ':') after property name", ) # --- # name: test_invalid_contentlines[invalid_iana-token] tuple( 'Calendar contents are not valid ICS format, see the detailed_error for more information', "Invalid property name 'DES.RIPTION'", ) # --- # name: test_invalid_contentlines[invalid_param_eol copy] tuple( 'Calendar contents are not valid ICS format, see the detailed_error for more information', 'Unexpected end of line: unclosed quoted parameter value.', ) # --- # name: test_invalid_contentlines[invalid_param_eol_quoted] tuple( 'Calendar contents are not valid ICS format, see the detailed_error for more information', 'Unexpected end of line: missing parameter value delimiter.', ) # --- # name: test_invalid_contentlines[invalid_param_name-1] tuple( 'Calendar contents are not valid ICS format, see the detailed_error for more information', "Invalid parameter name 'PARAM NAME'", ) # --- # name: test_invalid_contentlines[invalid_param_name-2] tuple( 'Calendar contents are not valid ICS format, see the detailed_error for more information', "Invalid parameter name 'PARAM+NAME'", ) # --- # name: test_invalid_contentlines[invalid_param_value] tuple( 'Calendar contents are not valid ICS format, see the detailed_error for more information', "Invalid parameter format: missing '=' after parameter name part ';:VALUE'", ) # --- # name: test_invalid_contentlines[invalid_params_list] tuple( 'Calendar contents are not valid ICS format, see the detailed_error for more information', 'Unexpected end of line. Expected parameter value or delimiter.', ) # --- # name: test_invalid_contentlines[invalid_quoted_missing_value] tuple( 'Calendar contents are not valid ICS format, see the detailed_error for more information', "Unexpected end of line after parameter value 'PARAM-VAL'. Expected delimiter (',', ';', ':').", ) # --- # name: test_invalid_contentlines[invalid_quoted_name] tuple( 'Calendar contents are not valid ICS format, see the detailed_error for more information', 'Invalid property name \'"NAME"\'', ) # --- # name: test_invalid_contentlines[invalid_quoted_param_extra_end] tuple( 'Calendar contents are not valid ICS format, see the detailed_error for more information', "Expected (',', ';', ':') after parameter value, got 'e'", ) # --- # name: test_invalid_contentlines[invalid_quoted_param_extra_start] tuple( 'Calendar contents are not valid ICS format, see the detailed_error for more information', 'Parameter value \'extra"QUOTED-VALUE"\' for parameter \'PARAM-NAME\' is improperly quoted', ) # --- # name: test_invalid_contentlines[invalid_quoted_params] tuple( 'Calendar contents are not valid ICS format, see the detailed_error for more information', 'Unexpected end of line: unclosed quoted parameter value.', ) # --- # name: test_invalid_contentlines[invalid_x-name-1] tuple( 'Calendar contents are not valid ICS format, see the detailed_error for more information', "Invalid property name 'X-DES.RIPTION'", ) # --- # name: test_invalid_contentlines[invalid_x-name-2] tuple( 'Calendar contents are not valid ICS format, see the detailed_error for more information', "Invalid property name 'X-,'", ) # --- # name: test_invalid_contentlines[missing_colon] tuple( 'Calendar contents are not valid ICS format, see the detailed_error for more information', "Invalid parameter format: missing '=' after parameter name part 'This is a description'", ) # --- # name: test_parse_contentlines[attendee] list([ dict({ 'name': 'vevent', 'properties': list([ dict({ 'name': 'attendee', 'params': list([ dict({ 'name': 'RSVP', 'values': list([ 'TRUE', ]), }), dict({ 'name': 'ROLE', 'values': list([ 'REQ-PARTICIPANT', ]), }), ]), 'value': 'mailto: jsmith@example.com', }), dict({ 'name': 'attendee', 'params': list([ dict({ 'name': 'DELEGATED-TO', 'values': list([ 'mailto:jdoe@example.com', 'mailto:jqpublic @example.com', ]), }), ]), 'value': 'mailto:jsmith@example.com', }), ]), }), ]) # --- # name: test_parse_contentlines[comma] list([ dict({ 'name': 'vevent', 'properties': list([ dict({ 'name': 'description', 'params': list([ dict({ 'name': 'ALTREP', 'values': list([ 'cid:part1.0001@example.org', ]), }), ]), 'value': "The Fall'98 Wild Wizards Conference - - Las Vegas\\, NV\\, USA", }), ]), }), ]) # --- # name: test_parse_contentlines[emoji] list([ dict({ 'components': list([ dict({ 'components': list([ ]), 'name': 'vevent', 'properties': list([ dict({ 'name': 'uid', 'params': None, 'value': '19970610T172345Z-AF23B2@example.com', }), dict({ 'name': 'dtstamp', 'params': None, 'value': '19970610T172345Z', }), dict({ 'name': 'dtstart', 'params': None, 'value': '19971225', }), dict({ 'name': 'dtend', 'params': None, 'value': '19971226', }), dict({ 'name': 'summary', 'params': None, 'value': '🎄', }), ]), }), ]), 'name': 'vcalendar', 'properties': list([ dict({ 'name': 'version', 'params': None, 'value': '2.0', }), dict({ 'name': 'prodid', 'params': None, 'value': '-//hacksw/handcal//NONSGML v1.0//EN', }), ]), }), ]) # --- # name: test_parse_contentlines[fold] list([ dict({ 'name': 'vevent', 'properties': list([ dict({ 'name': 'description', 'params': None, 'value': 'This is a lo ng description that exists on a long line.', }), ]), }), ]) # --- # name: test_parse_contentlines[icalendar_object] list([ dict({ 'components': list([ dict({ 'components': list([ ]), 'name': 'vevent', 'properties': list([ dict({ 'name': 'uid', 'params': None, 'value': '19970610T172345Z-AF23B2@example.com', }), dict({ 'name': 'dtstamp', 'params': None, 'value': '19970610T172345Z', }), dict({ 'name': 'dtstart', 'params': None, 'value': '19970714T170000Z', }), dict({ 'name': 'dtend', 'params': None, 'value': '19970715T040000Z', }), dict({ 'name': 'summary', 'params': None, 'value': 'Bastille Day Party', }), ]), }), ]), 'name': 'vcalendar', 'properties': list([ dict({ 'name': 'version', 'params': None, 'value': '2.0', }), dict({ 'name': 'prodid', 'params': None, 'value': '-//hacksw/handcal//NONSGML v1.0//EN', }), ]), }), ]) # --- # name: test_parse_contentlines[languages] list([ dict({ 'components': list([ dict({ 'components': list([ ]), 'name': 'vevent', 'properties': list([ dict({ 'name': 'uid', 'params': None, 'value': '19970610T172345Z-AF23B2@example.com', }), dict({ 'name': 'dtstamp', 'params': None, 'value': '19970610T172345Z', }), dict({ 'name': 'dtstart', 'params': None, 'value': '19970714T170000Z', }), dict({ 'name': 'dtend', 'params': None, 'value': '19970715T040000Z', }), dict({ 'name': 'summary', 'params': None, 'value': 'žmogus', }), ]), }), dict({ 'components': list([ ]), 'name': 'vevent', 'properties': list([ dict({ 'name': 'uid', 'params': None, 'value': '19970610T172345Z-AF23B3@example.com', }), dict({ 'name': 'dtstamp', 'params': None, 'value': '19970610T172345Z', }), dict({ 'name': 'dtstart', 'params': None, 'value': '19970714T170000Z', }), dict({ 'name': 'dtend', 'params': None, 'value': '19970715T040000Z', }), dict({ 'name': 'summary', 'params': None, 'value': '中文', }), ]), }), dict({ 'components': list([ ]), 'name': 'vevent', 'properties': list([ dict({ 'name': 'uid', 'params': None, 'value': '19970610T172345Z-AF23B4@example.com', }), dict({ 'name': 'dtstamp', 'params': None, 'value': '19970610T172345Z', }), dict({ 'name': 'dtstart', 'params': None, 'value': '19970714T170000Z', }), dict({ 'name': 'dtend', 'params': None, 'value': '19970715T040000Z', }), dict({ 'name': 'summary', 'params': None, 'value': 'кириллица', }), ]), }), dict({ 'components': list([ ]), 'name': 'vevent', 'properties': list([ dict({ 'name': 'uid', 'params': None, 'value': '19970610T172345Z-AF23B5@example.com', }), dict({ 'name': 'dtstamp', 'params': None, 'value': '19970610T172345Z', }), dict({ 'name': 'dtstart', 'params': None, 'value': '19970714T170000Z', }), dict({ 'name': 'dtend', 'params': None, 'value': '19970715T040000Z', }), dict({ 'name': 'summary', 'params': None, 'value': 'Ελληνικά', }), ]), }), dict({ 'components': list([ ]), 'name': 'vevent', 'properties': list([ dict({ 'name': 'uid', 'params': None, 'value': '19970610T172345Z-AF23B6@example.com', }), dict({ 'name': 'dtstamp', 'params': None, 'value': '19970610T172345Z', }), dict({ 'name': 'dtstart', 'params': None, 'value': '19970714T170000Z', }), dict({ 'name': 'dtend', 'params': None, 'value': '19970715T040000Z', }), dict({ 'name': 'summary', 'params': None, 'value': 'עִברִית', }), ]), }), dict({ 'components': list([ ]), 'name': 'vevent', 'properties': list([ dict({ 'name': 'uid', 'params': None, 'value': '19970610T172345Z-AF23B7@example.com', }), dict({ 'name': 'dtstamp', 'params': None, 'value': '19970610T172345Z', }), dict({ 'name': 'dtstart', 'params': None, 'value': '19970714T170000Z', }), dict({ 'name': 'dtend', 'params': None, 'value': '19970715T040000Z', }), dict({ 'name': 'summary', 'params': None, 'value': '日本語', }), ]), }), dict({ 'components': list([ ]), 'name': 'vevent', 'properties': list([ dict({ 'name': 'uid', 'params': None, 'value': '19970610T172345Z-AF23B8@example.com', }), dict({ 'name': 'dtstamp', 'params': None, 'value': '19970610T172345Z', }), dict({ 'name': 'dtstart', 'params': None, 'value': '19970714T170000Z', }), dict({ 'name': 'dtend', 'params': None, 'value': '19970715T040000Z', }), dict({ 'name': 'summary', 'params': None, 'value': '한국어', }), ]), }), dict({ 'components': list([ ]), 'name': 'vevent', 'properties': list([ dict({ 'name': 'uid', 'params': None, 'value': '19970610T172345Z-AF23B9@example.com', }), dict({ 'name': 'dtstamp', 'params': None, 'value': '19970610T172345Z', }), dict({ 'name': 'dtstart', 'params': None, 'value': '19970714T170000Z', }), dict({ 'name': 'dtend', 'params': None, 'value': '19970715T040000Z', }), dict({ 'name': 'summary', 'params': None, 'value': 'ไทย', }), ]), }), dict({ 'components': list([ ]), 'name': 'vevent', 'properties': list([ dict({ 'name': 'uid', 'params': None, 'value': '19970610T172345Z-AF23B10@example.com', }), dict({ 'name': 'dtstamp', 'params': None, 'value': '19970610T172345Z', }), dict({ 'name': 'dtstart', 'params': None, 'value': '19970714T170000Z', }), dict({ 'name': 'dtend', 'params': None, 'value': '19970715T040000Z', }), dict({ 'name': 'summary', 'params': None, 'value': 'देवनागरी', }), ]), }), ]), 'name': 'vcalendar', 'properties': list([ dict({ 'name': 'version', 'params': None, 'value': '2.0', }), dict({ 'name': 'prodid', 'params': None, 'value': '-//hacksw/handcal//NONSGML v1.0//EN', }), ]), }), ]) # --- # name: test_parse_contentlines[params] list([ dict({ 'name': 'vcalendar', 'properties': list([ dict({ 'name': 'name', 'params': list([ dict({ 'name': 'PARAM-NAME', 'values': list([ 'PARAM-VALUE1', ]), }), ]), 'value': 'VALUE', }), dict({ 'name': 'name', 'params': list([ dict({ 'name': 'PARAM-NAME', 'values': list([ 'PARAM+VALUE2', ]), }), ]), 'value': 'VALUE', }), dict({ 'name': 'name', 'params': list([ dict({ 'name': 'PARAM-NAME', 'values': list([ 'PARAM VALUE3', ]), }), ]), 'value': 'VALUE', }), dict({ 'name': 'name', 'params': list([ dict({ 'name': 'PARAM-NAME', 'values': list([ 'PARAM:VALUE4', ]), }), ]), 'value': 'VALUE', }), dict({ 'name': 'description', 'params': None, 'value': '', }), dict({ 'name': 'description', 'params': list([ dict({ 'name': 'TYPE', 'values': list([ '', ]), }), ]), 'value': '', }), ]), }), ]) # --- # name: test_parse_contentlines[params_empty] list([ dict({ 'name': 'vcalendar', 'properties': list([ dict({ 'name': 'name', 'params': list([ dict({ 'name': 'PARAM-NAME', 'values': list([ '', ]), }), ]), 'value': 'VALUE', }), dict({ 'name': 'name', 'params': list([ dict({ 'name': 'PARAM-NAME', 'values': list([ '', 'Value2', '', 'VALUE4', 'VALUE5', ]), }), ]), 'value': 'VALUE', }), ]), }), ]) # --- # name: test_parse_contentlines[params_quoted] list([ dict({ 'name': 'vcalendar', 'properties': list([ dict({ 'name': 'name', 'params': list([ dict({ 'name': 'PARAM-NAME', 'values': list([ 'PARAM-VALUE', ]), }), ]), 'value': 'VALUE', }), dict({ 'name': 'name', 'params': list([ dict({ 'name': 'PARAM-NAME', 'values': list([ 'PARAM:VALUE', ]), }), ]), 'value': 'VALUE', }), ]), }), ]) # --- # name: test_parse_contentlines[rdate] list([ dict({ 'name': 'vcalendar', 'properties': list([ dict({ 'name': 'rdate', 'params': list([ dict({ 'name': 'VALUE', 'values': list([ 'DATE', ]), }), ]), 'value': '19970304,19970504,19970704,19970904', }), ]), }), ]) # --- # name: test_parse_contentlines[unicode] list([ dict({ 'components': list([ dict({ 'components': list([ dict({ 'components': list([ ]), 'name': 'valarm', 'properties': list([ dict({ 'name': 'action', 'params': None, 'value': 'AUDIO', }), dict({ 'name': 'trigger', 'params': None, 'value': '19980403T120000Z', }), dict({ 'name': 'attach', 'params': list([ dict({ 'name': 'FILENAME', 'values': list([ 'Fødselsdag_40.pdf', ]), }), ]), 'value': 'https://someurl.com', }), dict({ 'name': 'repeat', 'params': None, 'value': '4', }), dict({ 'name': 'duration', 'params': None, 'value': 'PT1H', }), ]), }), ]), 'name': 'vtodo', 'properties': list([ dict({ 'name': 'dtstamp', 'params': None, 'value': '19980130T134500Z', }), dict({ 'name': 'sequence', 'params': None, 'value': '2', }), dict({ 'name': 'uid', 'params': None, 'value': 'uid4@example.com', }), dict({ 'name': 'due', 'params': None, 'value': '19980415T000000', }), dict({ 'name': 'status', 'params': None, 'value': 'NEEDS-ACTION', }), dict({ 'name': 'summary', 'params': None, 'value': 'Birthday', }), ]), }), ]), 'name': 'vcalendar', 'properties': list([ dict({ 'name': 'prodid', 'params': None, 'value': '-//ABC Corporation//NONSGML My Product//EN', }), dict({ 'name': 'version', 'params': None, 'value': '2.0', }), ]), }), ]) # --- # name: test_parse_contentlines[vcalendar_emoji] list([ dict({ 'name': 'vevent', 'properties': list([ dict({ 'name': 'dtstamp', 'params': None, 'value': '20221202T075310', }), dict({ 'name': 'uid', 'params': None, 'value': '5deea302-7216-11ed-b1b6-48d2240d04ae', }), dict({ 'name': 'dtstart', 'params': None, 'value': '20221202T085500', }), dict({ 'name': 'dtend', 'params': None, 'value': '20221202T090000', }), dict({ 'name': 'summary', 'params': None, 'value': '🎄emojis!', }), dict({ 'name': 'created', 'params': None, 'value': '20221202T075310', }), dict({ 'name': 'sequence', 'params': None, 'value': '0', }), ]), }), ]) # --- # name: test_parse_contentlines[vevent] list([ dict({ 'name': 'vevent', 'properties': list([ dict({ 'name': 'uid', 'params': None, 'value': '19970901T130000Z-123401@example.com', }), dict({ 'name': 'dtstamp', 'params': None, 'value': '19970901T130000Z', }), dict({ 'name': 'dtstart', 'params': None, 'value': '19970903T163000Z', }), dict({ 'name': 'dtend', 'params': None, 'value': '19970903T190000Z', }), dict({ 'name': 'summary', 'params': None, 'value': 'Annual Employee Review', }), dict({ 'name': 'class', 'params': None, 'value': 'PRIVATE', }), dict({ 'name': 'categories', 'params': None, 'value': 'BUSINESS,HUMAN RESOURCES', }), ]), }), ]) # --- # name: test_parse_contentlines[vtodo] list([ dict({ 'name': 'vtodo', 'properties': list([ dict({ 'name': 'uid', 'params': None, 'value': '20070313T123432Z-456553@example.com', }), dict({ 'name': 'dtstamp', 'params': None, 'value': '20070313T123432Z', }), dict({ 'name': 'due', 'params': list([ dict({ 'name': 'VALUE', 'values': list([ 'DATE', ]), }), ]), 'value': '20070501', }), dict({ 'name': 'summary', 'params': None, 'value': 'Submit Quebec Income Tax Return for 2006', }), dict({ 'name': 'class', 'params': None, 'value': 'CONFIDENTIAL', }), dict({ 'name': 'categories', 'params': None, 'value': 'FAMILY,FINANCE', }), dict({ 'name': 'status', 'params': None, 'value': 'NEEDS-ACTION', }), ]), }), ]) # --- # name: test_parse_contentlines[x-name] list([ dict({ 'name': 'vevent', 'properties': list([ dict({ 'name': 'x-description', 'params': None, 'value': 'This is a description', }), dict({ 'name': 'x-ical-description', 'params': None, 'value': 'This is a description', }), dict({ 'name': 'x-ical-1', 'params': None, 'value': 'This is another x-name', }), ]), }), ]) # --- allenporter-ical-fe8800b/tests/parsing/__snapshots__/test_property.ambr000066400000000000000000000331671510550726100266120ustar00rootroot00000000000000# serializer version: 1 # name: test_from_ics[attendee] list([ ParsedProperty(name='begin', value='VEVENT', params=None), ParsedProperty(name='attendee', value='mailto: jsmith@example.com', params=[ParsedPropertyParameter(name='RSVP', values=['TRUE']), ParsedPropertyParameter(name='ROLE', values=['REQ-PARTICIPANT'])]), ParsedProperty(name='attendee', value='mailto:jsmith@example.com', params=[ParsedPropertyParameter(name='DELEGATED-TO', values=['mailto:jdoe@example.com', 'mailto:jqpublic @example.com'])]), ParsedProperty(name='end', value='VEVENT', params=None), ]) # --- # name: test_from_ics[comma] list([ ParsedProperty(name='begin', value='VEVENT', params=None), ParsedProperty(name='description', value="The Fall'98 Wild Wizards Conference - - Las Vegas\\, NV\\, USA", params=[ParsedPropertyParameter(name='ALTREP', values=['cid:part1.0001@example.org'])]), ParsedProperty(name='end', value='VEVENT', params=None), ]) # --- # name: test_from_ics[emoji] list([ ParsedProperty(name='begin', value='VCALENDAR', params=None), ParsedProperty(name='version', value='2.0', params=None), ParsedProperty(name='prodid', value='-//hacksw/handcal//NONSGML v1.0//EN', params=None), ParsedProperty(name='begin', value='VEVENT', params=None), ParsedProperty(name='uid', value='19970610T172345Z-AF23B2@example.com', params=None), ParsedProperty(name='dtstamp', value='19970610T172345Z', params=None), ParsedProperty(name='dtstart', value='19971225', params=None), ParsedProperty(name='dtend', value='19971226', params=None), ParsedProperty(name='summary', value='🎄', params=None), ParsedProperty(name='end', value='VEVENT', params=None), ParsedProperty(name='end', value='VCALENDAR', params=None), ]) # --- # name: test_from_ics[fold] list([ ParsedProperty(name='begin', value='VEVENT', params=None), ParsedProperty(name='description', value='This is a lo ng description that exists on a long line.', params=None), ParsedProperty(name='end', value='VEVENT', params=None), ]) # --- # name: test_from_ics[icalendar_object] list([ ParsedProperty(name='begin', value='VCALENDAR', params=None), ParsedProperty(name='version', value='2.0', params=None), ParsedProperty(name='prodid', value='-//hacksw/handcal//NONSGML v1.0//EN', params=None), ParsedProperty(name='begin', value='VEVENT', params=None), ParsedProperty(name='uid', value='19970610T172345Z-AF23B2@example.com', params=None), ParsedProperty(name='dtstamp', value='19970610T172345Z', params=None), ParsedProperty(name='dtstart', value='19970714T170000Z', params=None), ParsedProperty(name='dtend', value='19970715T040000Z', params=None), ParsedProperty(name='summary', value='Bastille Day Party', params=None), ParsedProperty(name='end', value='VEVENT', params=None), ParsedProperty(name='end', value='VCALENDAR', params=None), ]) # --- # name: test_from_ics[languages] list([ ParsedProperty(name='begin', value='VCALENDAR', params=None), ParsedProperty(name='version', value='2.0', params=None), ParsedProperty(name='prodid', value='-//hacksw/handcal//NONSGML v1.0//EN', params=None), ParsedProperty(name='begin', value='VEVENT', params=None), ParsedProperty(name='uid', value='19970610T172345Z-AF23B2@example.com', params=None), ParsedProperty(name='dtstamp', value='19970610T172345Z', params=None), ParsedProperty(name='dtstart', value='19970714T170000Z', params=None), ParsedProperty(name='dtend', value='19970715T040000Z', params=None), ParsedProperty(name='summary', value='žmogus', params=None), ParsedProperty(name='end', value='VEVENT', params=None), ParsedProperty(name='begin', value='VEVENT', params=None), ParsedProperty(name='uid', value='19970610T172345Z-AF23B3@example.com', params=None), ParsedProperty(name='dtstamp', value='19970610T172345Z', params=None), ParsedProperty(name='dtstart', value='19970714T170000Z', params=None), ParsedProperty(name='dtend', value='19970715T040000Z', params=None), ParsedProperty(name='summary', value='中文', params=None), ParsedProperty(name='end', value='VEVENT', params=None), ParsedProperty(name='begin', value='VEVENT', params=None), ParsedProperty(name='uid', value='19970610T172345Z-AF23B4@example.com', params=None), ParsedProperty(name='dtstamp', value='19970610T172345Z', params=None), ParsedProperty(name='dtstart', value='19970714T170000Z', params=None), ParsedProperty(name='dtend', value='19970715T040000Z', params=None), ParsedProperty(name='summary', value='кириллица', params=None), ParsedProperty(name='end', value='VEVENT', params=None), ParsedProperty(name='begin', value='VEVENT', params=None), ParsedProperty(name='uid', value='19970610T172345Z-AF23B5@example.com', params=None), ParsedProperty(name='dtstamp', value='19970610T172345Z', params=None), ParsedProperty(name='dtstart', value='19970714T170000Z', params=None), ParsedProperty(name='dtend', value='19970715T040000Z', params=None), ParsedProperty(name='summary', value='Ελληνικά', params=None), ParsedProperty(name='end', value='VEVENT', params=None), ParsedProperty(name='begin', value='VEVENT', params=None), ParsedProperty(name='uid', value='19970610T172345Z-AF23B6@example.com', params=None), ParsedProperty(name='dtstamp', value='19970610T172345Z', params=None), ParsedProperty(name='dtstart', value='19970714T170000Z', params=None), ParsedProperty(name='dtend', value='19970715T040000Z', params=None), ParsedProperty(name='summary', value='עִברִית', params=None), ParsedProperty(name='end', value='VEVENT', params=None), ParsedProperty(name='begin', value='VEVENT', params=None), ParsedProperty(name='uid', value='19970610T172345Z-AF23B7@example.com', params=None), ParsedProperty(name='dtstamp', value='19970610T172345Z', params=None), ParsedProperty(name='dtstart', value='19970714T170000Z', params=None), ParsedProperty(name='dtend', value='19970715T040000Z', params=None), ParsedProperty(name='summary', value='日本語', params=None), ParsedProperty(name='end', value='VEVENT', params=None), ParsedProperty(name='begin', value='VEVENT', params=None), ParsedProperty(name='uid', value='19970610T172345Z-AF23B8@example.com', params=None), ParsedProperty(name='dtstamp', value='19970610T172345Z', params=None), ParsedProperty(name='dtstart', value='19970714T170000Z', params=None), ParsedProperty(name='dtend', value='19970715T040000Z', params=None), ParsedProperty(name='summary', value='한국어', params=None), ParsedProperty(name='end', value='VEVENT', params=None), ParsedProperty(name='begin', value='VEVENT', params=None), ParsedProperty(name='uid', value='19970610T172345Z-AF23B9@example.com', params=None), ParsedProperty(name='dtstamp', value='19970610T172345Z', params=None), ParsedProperty(name='dtstart', value='19970714T170000Z', params=None), ParsedProperty(name='dtend', value='19970715T040000Z', params=None), ParsedProperty(name='summary', value='ไทย', params=None), ParsedProperty(name='end', value='VEVENT', params=None), ParsedProperty(name='begin', value='VEVENT', params=None), ParsedProperty(name='uid', value='19970610T172345Z-AF23B10@example.com', params=None), ParsedProperty(name='dtstamp', value='19970610T172345Z', params=None), ParsedProperty(name='dtstart', value='19970714T170000Z', params=None), ParsedProperty(name='dtend', value='19970715T040000Z', params=None), ParsedProperty(name='summary', value='देवनागरी', params=None), ParsedProperty(name='end', value='VEVENT', params=None), ParsedProperty(name='end', value='VCALENDAR', params=None), ]) # --- # name: test_from_ics[params] list([ ParsedProperty(name='begin', value='VCALENDAR', params=None), ParsedProperty(name='name', value='VALUE', params=[ParsedPropertyParameter(name='PARAM-NAME', values=['PARAM-VALUE1'])]), ParsedProperty(name='name', value='VALUE', params=[ParsedPropertyParameter(name='PARAM-NAME', values=['PARAM+VALUE2'])]), ParsedProperty(name='name', value='VALUE', params=[ParsedPropertyParameter(name='PARAM-NAME', values=['PARAM VALUE3'])]), ParsedProperty(name='name', value='VALUE', params=[ParsedPropertyParameter(name='PARAM-NAME', values=['PARAM:VALUE4'])]), ParsedProperty(name='description', value='', params=None), ParsedProperty(name='description', value='', params=[ParsedPropertyParameter(name='TYPE', values=[''])]), ParsedProperty(name='end', value='VCALENDAR', params=None), ]) # --- # name: test_from_ics[params_empty] list([ ParsedProperty(name='begin', value='VCALENDAR', params=None), ParsedProperty(name='name', value='VALUE', params=[ParsedPropertyParameter(name='PARAM-NAME', values=[''])]), ParsedProperty(name='name', value='VALUE', params=[ParsedPropertyParameter(name='PARAM-NAME', values=['', 'Value2', '', 'VALUE4', 'VALUE5'])]), ParsedProperty(name='end', value='VCALENDAR', params=None), ]) # --- # name: test_from_ics[params_quoted] list([ ParsedProperty(name='begin', value='VCALENDAR', params=None), ParsedProperty(name='name', value='VALUE', params=[ParsedPropertyParameter(name='PARAM-NAME', values=['PARAM-VALUE'])]), ParsedProperty(name='name', value='VALUE', params=[ParsedPropertyParameter(name='PARAM-NAME', values=['PARAM:VALUE'])]), ParsedProperty(name='end', value='VCALENDAR', params=None), ]) # --- # name: test_from_ics[rdate] list([ ParsedProperty(name='begin', value='VCALENDAR', params=None), ParsedProperty(name='rdate', value='19970304,19970504,19970704,19970904', params=[ParsedPropertyParameter(name='VALUE', values=['DATE'])]), ParsedProperty(name='end', value='VCALENDAR', params=None), ]) # --- # name: test_from_ics[unicode] list([ ParsedProperty(name='begin', value='VCALENDAR', params=None), ParsedProperty(name='prodid', value='-//ABC Corporation//NONSGML My Product//EN', params=None), ParsedProperty(name='version', value='2.0', params=None), ParsedProperty(name='begin', value='VTODO', params=None), ParsedProperty(name='dtstamp', value='19980130T134500Z', params=None), ParsedProperty(name='sequence', value='2', params=None), ParsedProperty(name='uid', value='uid4@example.com', params=None), ParsedProperty(name='due', value='19980415T000000', params=None), ParsedProperty(name='status', value='NEEDS-ACTION', params=None), ParsedProperty(name='summary', value='Birthday', params=None), ParsedProperty(name='begin', value='VALARM', params=None), ParsedProperty(name='action', value='AUDIO', params=None), ParsedProperty(name='trigger', value='19980403T120000Z', params=None), ParsedProperty(name='attach', value='https://someurl.com', params=[ParsedPropertyParameter(name='FILENAME', values=['Fødselsdag_40.pdf'])]), ParsedProperty(name='repeat', value='4', params=None), ParsedProperty(name='duration', value='PT1H', params=None), ParsedProperty(name='end', value='VALARM', params=None), ParsedProperty(name='end', value='VTODO', params=None), ParsedProperty(name='end', value='VCALENDAR', params=None), ]) # --- # name: test_from_ics[vcalendar_emoji] list([ ParsedProperty(name='begin', value='VEVENT', params=None), ParsedProperty(name='dtstamp', value='20221202T075310', params=None), ParsedProperty(name='uid', value='5deea302-7216-11ed-b1b6-48d2240d04ae', params=None), ParsedProperty(name='dtstart', value='20221202T085500', params=None), ParsedProperty(name='dtend', value='20221202T090000', params=None), ParsedProperty(name='summary', value='🎄emojis!', params=None), ParsedProperty(name='created', value='20221202T075310', params=None), ParsedProperty(name='sequence', value='0', params=None), ParsedProperty(name='end', value='VEVENT', params=None), ]) # --- # name: test_from_ics[vevent] list([ ParsedProperty(name='begin', value='VEVENT', params=None), ParsedProperty(name='uid', value='19970901T130000Z-123401@example.com', params=None), ParsedProperty(name='dtstamp', value='19970901T130000Z', params=None), ParsedProperty(name='dtstart', value='19970903T163000Z', params=None), ParsedProperty(name='dtend', value='19970903T190000Z', params=None), ParsedProperty(name='summary', value='Annual Employee Review', params=None), ParsedProperty(name='class', value='PRIVATE', params=None), ParsedProperty(name='categories', value='BUSINESS,HUMAN RESOURCES', params=None), ParsedProperty(name='end', value='VEVENT', params=None), ]) # --- # name: test_from_ics[vtodo] list([ ParsedProperty(name='begin', value='VTODO', params=None), ParsedProperty(name='uid', value='20070313T123432Z-456553@example.com', params=None), ParsedProperty(name='dtstamp', value='20070313T123432Z', params=None), ParsedProperty(name='due', value='20070501', params=[ParsedPropertyParameter(name='VALUE', values=['DATE'])]), ParsedProperty(name='summary', value='Submit Quebec Income Tax Return for 2006', params=None), ParsedProperty(name='class', value='CONFIDENTIAL', params=None), ParsedProperty(name='categories', value='FAMILY,FINANCE', params=None), ParsedProperty(name='status', value='NEEDS-ACTION', params=None), ParsedProperty(name='end', value='VTODO', params=None), ]) # --- # name: test_from_ics[x-name] list([ ParsedProperty(name='begin', value='VEVENT', params=None), ParsedProperty(name='x-description', value='This is a description', params=None), ParsedProperty(name='x-ical-description', value='This is a description', params=None), ParsedProperty(name='x-ical-1', value='This is another x-name', params=None), ParsedProperty(name='end', value='VEVENT', params=None), ]) # --- allenporter-ical-fe8800b/tests/parsing/test_component.py000066400000000000000000000042721510550726100236140ustar00rootroot00000000000000"""Tests for parsing raw components.""" import json import pathlib from typing import Any import pytest from syrupy import SnapshotAssertion from ical.exceptions import CalendarParseError from ical.parsing.component import encode_content, parse_content TESTDATA_PATH = pathlib.Path("tests/parsing/testdata/valid/") TESTDATA_FILES = list(TESTDATA_PATH.glob("*.ics")) TESTDATA_IDS = [x.stem for x in TESTDATA_FILES] INVALID_TESTDATA_PATH = pathlib.Path("tests/parsing/testdata/invalid/") INVALID_TESTDATA_FILES = list(INVALID_TESTDATA_PATH.glob("*.ics")) INVALID_TESTDATA_IDS = [x.stem for x in INVALID_TESTDATA_FILES] @pytest.mark.parametrize("filename", TESTDATA_FILES, ids=TESTDATA_IDS) def test_parse_contentlines( filename: pathlib.Path, snapshot: SnapshotAssertion, json_encoder: json.JSONEncoder ) -> None: """Fixture to read golden file and compare to golden output.""" values = parse_content(filename.read_text()) values = json.loads(json_encoder.encode(values)) assert values == snapshot @pytest.mark.parametrize("filename", TESTDATA_FILES, ids=TESTDATA_IDS) def test_encode_contentlines( filename: pathlib.Path, snapshot: SnapshotAssertion ) -> None: """Fixture to read golden file and serialize back to same format.""" values = parse_content(filename.read_text()) ics = encode_content(values) assert ics == snapshot @pytest.mark.parametrize("filename", INVALID_TESTDATA_FILES, ids=INVALID_TESTDATA_IDS) def test_invalid_contentlines( filename: pathlib.Path, snapshot: SnapshotAssertion, json_encoder: json.JSONEncoder ) -> None: """Fixture to read file inputs that should fail parsing.""" with pytest.raises(CalendarParseError) as exc_info: parse_content(filename.read_text()) assert (str(exc_info.value), exc_info.value.detailed_error) == snapshot @pytest.mark.parametrize("filename", TESTDATA_FILES, ids=TESTDATA_IDS) def test_parse_contentlines_benchmark( filename: pathlib.Path, json_encoder: json.JSONEncoder, benchmark: Any ) -> None: """Benchmark to measure the speed of parsing.""" def parse() -> None: values = parse_content(filename.read_text()) json.loads(json_encoder.encode(values)) benchmark(parse) allenporter-ical-fe8800b/tests/parsing/test_property.py000066400000000000000000000047621510550726100235020ustar00rootroot00000000000000"""Tests for handling rfc5545 properties and parameters. This reuses the test data made for testing full components, but also exercises the same lower level components. """ from dataclasses import asdict import pathlib import pytest from syrupy import SnapshotAssertion from ical.exceptions import CalendarParseError from ical.parsing.property import ( ParsedProperty, ParsedPropertyParameter, parse_contentlines, ) from ical.parsing.component import unfolded_lines TESTDATA_PATH = pathlib.Path("tests/parsing/testdata/valid/") TESTDATA_FILES = list(TESTDATA_PATH.glob("*.ics")) TESTDATA_IDS = [x.stem for x in TESTDATA_FILES] @pytest.mark.parametrize("filename", TESTDATA_FILES, ids=TESTDATA_IDS) def test_from_ics(filename: str, snapshot: SnapshotAssertion) -> None: """Fixture to read golden file and compare to golden output.""" properties = list(parse_contentlines(unfolded_lines(filename.read_text()))) assert properties == snapshot @pytest.mark.parametrize( "ics", [ "PROP-VALUE", "PROP;:VALUE", "PROP;PARAM:VALUE", ";VALUE", ";:VALUE", ], ) def test_invalid_format(ics: str) -> None: """Test parsing invalid property format.""" with pytest.raises(CalendarParseError): list(parse_contentlines([ics])) @pytest.mark.parametrize( "ics", [ "X-TEST-BLANK;VALUE=URI;X-TEST-BLANK-PARAM=:VALUE", "X-TEST-BLANK;VALUE=URI;X-TEST-BLANK-PARAM=:VALUE", "X-TEST-BLANK;VALUE=URI;X-TEST-BLANK-PARAM=:VALUE", "X-TEST-BLANK;VALUE=URI;X-TEST-BLANK-PARAM=:VALUE", ], ) def test_blank_parameters(ics: str) -> None: """Test parsing invalid property format.""" properties = list(parse_contentlines([ics])) assert len(properties) == 1 prop = properties[0] assert prop.name == "x-test-blank" assert prop.value == "VALUE" assert len(prop.params) == 2 assert prop.params[0].name == "VALUE" assert prop.params[0].values == ["URI"] assert prop.params[1].name == "X-TEST-BLANK-PARAM" assert prop.params[1].values == [""] @pytest.mark.parametrize( "ics", [ "BEGIN:VEVENT", "begin:VEVENT", "Begin:VEVENT", "bEgiN:VEVENT", ], ) def test_mixed_case_property_name(ics: str) -> None: """Test property name is case-insensitive.""" properties = list(parse_contentlines([ics])) assert len(properties) == 1 prop = properties[0] assert prop.name == "begin" assert prop.value == "VEVENT" assert prop.params is None allenporter-ical-fe8800b/tests/parsing/testdata/000077500000000000000000000000001510550726100220055ustar00rootroot00000000000000allenporter-ical-fe8800b/tests/parsing/testdata/invalid/000077500000000000000000000000001510550726100234335ustar00rootroot00000000000000allenporter-ical-fe8800b/tests/parsing/testdata/invalid/invalid.ics000066400000000000000000000000101510550726100255500ustar00rootroot00000000000000invalid allenporter-ical-fe8800b/tests/parsing/testdata/invalid/invalid_iana-token.ics000066400000000000000000000000721510550726100276660ustar00rootroot00000000000000BEGIN:VEVENT DES.RIPTION:This is a description END:VEVENT allenporter-ical-fe8800b/tests/parsing/testdata/invalid/invalid_param_eol copy.ics000066400000000000000000000000711510550726100305310ustar00rootroot00000000000000BEGIN:VCALENDAR NAME;PARAM-NAME="PARAM-VAL END:VCALENDAR allenporter-ical-fe8800b/tests/parsing/testdata/invalid/invalid_param_eol_quoted.ics000066400000000000000000000000671510550726100311640ustar00rootroot00000000000000BEGIN:VCALENDAR NAME;PARAM-NAME=PARAMVAL END:VCALENDAR allenporter-ical-fe8800b/tests/parsing/testdata/invalid/invalid_param_name-1.ics000066400000000000000000000000721510550726100300760ustar00rootroot00000000000000BEGIN:VEVENT NAME;PARAM NAME=PARAM-VALUE:VALUE END:VEVENT allenporter-ical-fe8800b/tests/parsing/testdata/invalid/invalid_param_name-2.ics000066400000000000000000000000721510550726100300770ustar00rootroot00000000000000BEGIN:VEVENT NAME;PARAM+NAME=PARAM-VALUE:VALUE END:VEVENT allenporter-ical-fe8800b/tests/parsing/testdata/invalid/invalid_param_value.ics000066400000000000000000000000621510550726100301330ustar00rootroot00000000000000BEGIN:VCALENDAR DESCRIPTION;;:VALUE END:VCALENDAR allenporter-ical-fe8800b/tests/parsing/testdata/invalid/invalid_params_list.ics000066400000000000000000000000601510550726100301530ustar00rootroot00000000000000BEGIN:VCALENDAR NAME;PARAM-NAME=, END:VCALENDAR allenporter-ical-fe8800b/tests/parsing/testdata/invalid/invalid_quoted_missing_value.ics000066400000000000000000000000721510550726100320660ustar00rootroot00000000000000BEGIN:VCALENDAR NAME;PARAM-NAME="PARAM-VAL" END:VCALENDAR allenporter-ical-fe8800b/tests/parsing/testdata/invalid/invalid_quoted_name.ics000066400000000000000000000000531510550726100301400ustar00rootroot00000000000000BEGIN:VCALENDAR "NAME":VALUE END:VCALENDAR allenporter-ical-fe8800b/tests/parsing/testdata/invalid/invalid_quoted_param_extra_end.ics000066400000000000000000000001101510550726100323430ustar00rootroot00000000000000BEGIN:VCALENDAR NAME;PARAM-NAME="QUOTED-VALUE"extra:VALUE END:VCALENDAR allenporter-ical-fe8800b/tests/parsing/testdata/invalid/invalid_quoted_param_extra_start.ics000066400000000000000000000001101510550726100327320ustar00rootroot00000000000000BEGIN:VCALENDAR NAME;PARAM-NAME=extra"QUOTED-VALUE":VALUE END:VCALENDAR allenporter-ical-fe8800b/tests/parsing/testdata/invalid/invalid_quoted_params.ics000066400000000000000000000001171510550726100305040ustar00rootroot00000000000000BEGIN:VCALENDAR NAME;PARAM-NAME="QUOTED-PARAM:QUOTED-VALUE:VALUE END:VCALENDAR allenporter-ical-fe8800b/tests/parsing/testdata/invalid/invalid_x-name-1.ics000066400000000000000000000000741510550726100271650ustar00rootroot00000000000000BEGIN:VEVENT X-DES.RIPTION:This is a description END:VEVENT allenporter-ical-fe8800b/tests/parsing/testdata/invalid/invalid_x-name-2.ics000066400000000000000000000000621510550726100271630ustar00rootroot00000000000000BEGIN:VEVENT X-,:This is a description END:VEVENT allenporter-ical-fe8800b/tests/parsing/testdata/invalid/missing_colon.ics000066400000000000000000000000721510550726100267750ustar00rootroot00000000000000BEGIN:VEVENT DESCRIPTION;This is a description END:VEVENT allenporter-ical-fe8800b/tests/parsing/testdata/valid/000077500000000000000000000000001510550726100231045ustar00rootroot00000000000000allenporter-ical-fe8800b/tests/parsing/testdata/valid/attendee.ics000066400000000000000000000003101510550726100253670ustar00rootroot00000000000000BEGIN:VEVENT ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT:mailto: jsmith@example.com ATTENDEE;DELEGATED-TO="mailto:jdoe@example.com","mailto:jqpublic @example.com":mailto:jsmith@example.com END:VEVENT allenporter-ical-fe8800b/tests/parsing/testdata/valid/comma.ics000066400000000000000000000002071510550726100246770ustar00rootroot00000000000000BEGIN:VEVENT DESCRIPTION;ALTREP="cid:part1.0001@example.org":The Fall'98 Wild Wizards Conference - - Las Vegas\, NV\, USA END:VEVENT allenporter-ical-fe8800b/tests/parsing/testdata/valid/emoji.ics000066400000000000000000000003331510550726100247060ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//hacksw/handcal//NONSGML v1.0//EN BEGIN:VEVENT UID:19970610T172345Z-AF23B2@example.com DTSTAMP:19970610T172345Z DTSTART:19971225 DTEND:19971226 SUMMARY:🎄 END:VEVENT END:VCALENDAR allenporter-ical-fe8800b/tests/parsing/testdata/valid/fold.ics000066400000000000000000000001401510550726100245230ustar00rootroot00000000000000BEGIN:VEVENT DESCRIPTION:This is a lo ng description that exists on a long line. END:VEVENT allenporter-ical-fe8800b/tests/parsing/testdata/valid/icalendar_object.ics000066400000000000000000000003711510550726100270550ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//hacksw/handcal//NONSGML v1.0//EN BEGIN:VEVENT UID:19970610T172345Z-AF23B2@example.com DTSTAMP:19970610T172345Z DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:Bastille Day Party END:VEVENT END:VCALENDAR allenporter-ical-fe8800b/tests/parsing/testdata/valid/languages.ics000066400000000000000000000027501510550726100255560ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//hacksw/handcal//NONSGML v1.0//EN BEGIN:VEVENT UID:19970610T172345Z-AF23B2@example.com DTSTAMP:19970610T172345Z DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:žmogus END:VEVENT BEGIN:VEVENT UID:19970610T172345Z-AF23B3@example.com DTSTAMP:19970610T172345Z DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:中文 END:VEVENT BEGIN:VEVENT UID:19970610T172345Z-AF23B4@example.com DTSTAMP:19970610T172345Z DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:кириллица END:VEVENT BEGIN:VEVENT UID:19970610T172345Z-AF23B5@example.com DTSTAMP:19970610T172345Z DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:Ελληνικά END:VEVENT BEGIN:VEVENT UID:19970610T172345Z-AF23B6@example.com DTSTAMP:19970610T172345Z DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:עִברִית END:VEVENT BEGIN:VEVENT UID:19970610T172345Z-AF23B7@example.com DTSTAMP:19970610T172345Z DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:日本語 END:VEVENT BEGIN:VEVENT UID:19970610T172345Z-AF23B8@example.com DTSTAMP:19970610T172345Z DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:한국어 END:VEVENT BEGIN:VEVENT UID:19970610T172345Z-AF23B9@example.com DTSTAMP:19970610T172345Z DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:ไทย END:VEVENT BEGIN:VEVENT UID:19970610T172345Z-AF23B10@example.com DTSTAMP:19970610T172345Z DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:देवनागरी END:VEVENT END:VCALENDAR allenporter-ical-fe8800b/tests/parsing/testdata/valid/params.ics000066400000000000000000000003141510550726100250650ustar00rootroot00000000000000BEGIN:VCALENDAR NAME;PARAM-NAME=PARAM-VALUE1:VALUE NAME;PARAM-NAME=PARAM+VALUE2:VALUE NAME;PARAM-NAME=PARAM VALUE3:VALUE NAME;PARAM-NAME="PARAM:VALUE4":VALUE DESCRIPTION: DESCRIPTION;TYPE=: END:VCALENDAR allenporter-ical-fe8800b/tests/parsing/testdata/valid/params_empty.ics000066400000000000000000000001521510550726100263030ustar00rootroot00000000000000BEGIN:VCALENDAR NAME;PARAM-NAME="":VALUE NAME;PARAM-NAME="",Value2,"",VALUE4,"VALUE5":VALUE END:VCALENDAR allenporter-ical-fe8800b/tests/parsing/testdata/valid/params_quoted.ics000066400000000000000000000001461510550726100264510ustar00rootroot00000000000000BEGIN:VCALENDAR NAME;PARAM-NAME="PARAM-VALUE":VALUE NAME;PARAM-NAME="PARAM:VALUE":VALUE END:VCALENDAR allenporter-ical-fe8800b/tests/parsing/testdata/valid/rdate.ics000066400000000000000000000001231510550726100246770ustar00rootroot00000000000000BEGIN:VCALENDAR RDATE;VALUE=DATE:19970304,19970504,19970704,19970904 END:VCALENDAR allenporter-ical-fe8800b/tests/parsing/testdata/valid/unicode.ics000066400000000000000000000005601510550726100252330ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//ABC Corporation//NONSGML My Product//EN VERSION:2.0 BEGIN:VTODO DTSTAMP:19980130T134500Z SEQUENCE:2 UID:uid4@example.com DUE:19980415T000000 STATUS:NEEDS-ACTION SUMMARY:Birthday BEGIN:VALARM ACTION:AUDIO TRIGGER:19980403T120000Z ATTACH;FILENAME=Fødselsdag_40.pdf:https://someurl.com REPEAT:4 DURATION:PT1H END:VALARM END:VTODO END:VCALENDAR allenporter-ical-fe8800b/tests/parsing/testdata/valid/vcalendar_emoji.ics000066400000000000000000000002761510550726100267330ustar00rootroot00000000000000BEGIN:VEVENT DTSTAMP:20221202T075310 UID:5deea302-7216-11ed-b1b6-48d2240d04ae DTSTART:20221202T085500 DTEND:20221202T090000 SUMMARY:🎄emojis! CREATED:20221202T075310 SEQUENCE:0 END:VEVENT allenporter-ical-fe8800b/tests/parsing/testdata/valid/vevent.ics000066400000000000000000000003321510550726100251110ustar00rootroot00000000000000BEGIN:VEVENT UID:19970901T130000Z-123401@example.com DTSTAMP:19970901T130000Z DTSTART:19970903T163000Z DTEND:19970903T190000Z SUMMARY:Annual Employee Review CLASS:PRIVATE CATEGORIES:BUSINESS,HUMAN RESOURCES END:VEVENT allenporter-ical-fe8800b/tests/parsing/testdata/valid/vtodo.ics000066400000000000000000000003411510550726100247350ustar00rootroot00000000000000BEGIN:VTODO UID:20070313T123432Z-456553@example.com DTSTAMP:20070313T123432Z DUE;VALUE=DATE:20070501 SUMMARY:Submit Quebec Income Tax Return for 2006 CLASS:CONFIDENTIAL CATEGORIES:FAMILY,FINANCE STATUS:NEEDS-ACTION END:VTODO allenporter-ical-fe8800b/tests/parsing/testdata/valid/x-name.ics000066400000000000000000000002051510550726100247660ustar00rootroot00000000000000BEGIN:VEVENT X-DESCRIPTION:This is a description X-ICAL-DESCRIPTION:This is a description X-ICAL-1:This is another x-name END:VEVENT allenporter-ical-fe8800b/tests/test_alarm.py000066400000000000000000000060171510550726100212420ustar00rootroot00000000000000"""Tests for Alarm component.""" from __future__ import annotations import datetime import pytest from ical.alarm import Alarm from ical.exceptions import CalendarParseError def test_todo() -> None: """Test a valid Alarm.""" alarm = Alarm(action="AUDIO", trigger=datetime.timedelta(minutes=-5)) assert alarm.action == "AUDIO" assert alarm.trigger == datetime.timedelta(minutes=-5) assert not alarm.duration assert not alarm.repeat def test_duration_and_repeat() -> None: """Test relationship between the duration and repeat fields.""" alarm = Alarm( action="AUDIO", trigger=datetime.timedelta(minutes=-5), duration=datetime.timedelta(seconds=30), repeat=2, ) assert alarm.action assert alarm.trigger assert alarm.duration assert alarm.repeat == 2 # Duration but no repeat with pytest.raises(CalendarParseError): Alarm( action="AUDIO", trigger=datetime.timedelta(minutes=-5), duration=datetime.timedelta(seconds=30), ) # Repeat but no duration with pytest.raises(CalendarParseError): Alarm(action="AUDIO", trigger=datetime.timedelta(minutes=-5), repeat=2) def test_display_required_fields() -> None: """Test required fields for action DISPLAY.""" with pytest.raises( CalendarParseError, match="Description value is required for action DISPLAY" ): Alarm(action="DISPLAY", trigger=datetime.timedelta(minutes=-5)) alarm = Alarm( action="DISPLAY", trigger=datetime.timedelta(minutes=-5), description="Notification description", ) assert alarm.action == "DISPLAY" assert alarm.description == "Notification description" def test_empty_display_field() -> None: """Test required fields for action DISPLAY.""" alarm = Alarm( action="DISPLAY", trigger=datetime.timedelta(minutes=-5), description="", ) assert alarm.action == "DISPLAY" assert alarm.description == "" def test_email_required_fields() -> None: """Test required fields for action EMAIL.""" # Missing multiple fields with pytest.raises( CalendarParseError, match="Description value is required for action EMAIL" ): Alarm(action="EMAIL", trigger=datetime.timedelta(minutes=-5)) # Missing summary with pytest.raises(CalendarParseError): Alarm( action="EMAIL", trigger=datetime.timedelta(minutes=-5), description="Email description", ) # Missing description with pytest.raises(CalendarParseError): Alarm( action="EMAIL", trigger=datetime.timedelta(minutes=-5), summary="Email summary", ) alarm = Alarm( action="DISPLAY", trigger=datetime.timedelta(minutes=-5), description="Email description", summary="Email summary", ) assert alarm.action == "DISPLAY" assert alarm.summary == "Email summary" assert alarm.description == "Email description" allenporter-ical-fe8800b/tests/test_calendar.py000066400000000000000000000340351510550726100217200ustar00rootroot00000000000000"""Tests for timeline related calendar eents.""" from __future__ import annotations from collections.abc import Generator import datetime import re import uuid import zoneinfo from unittest.mock import patch import pytest from freezegun import freeze_time from ical.calendar import Calendar from ical.calendar_stream import IcsCalendarStream from ical.event import Event from ical.types.recur import Recur @pytest.fixture(name="calendar") def mock_calendar() -> Calendar: """Fixture calendar with all day events to use in tests.""" cal = Calendar() cal.events.extend( [ Event( summary="second", start=datetime.date(2000, 2, 1), end=datetime.date(2000, 2, 2), ), Event( summary="fourth", start=datetime.date(2000, 4, 1), end=datetime.date(2000, 4, 2), ), Event( summary="third", start=datetime.date(2000, 3, 1), end=datetime.date(2000, 3, 2), ), Event( summary="first", start=datetime.date(2000, 1, 1), end=datetime.date(2000, 1, 2), ), ] ) return cal @pytest.fixture(name="calendar_times") def mock_calendar_times() -> Calendar: """Fixture calendar with datetime based events to use in tests.""" cal = Calendar() cal.events.extend( [ Event( summary="first", start=datetime.datetime(2000, 1, 1, 11, 0), end=datetime.datetime(2000, 1, 1, 11, 30), ), Event( summary="second", start=datetime.datetime(2000, 1, 1, 12, 0), end=datetime.datetime(2000, 1, 1, 13, 0), ), Event( summary="third", start=datetime.datetime(2000, 1, 2, 12, 0), end=datetime.datetime(2000, 1, 2, 13, 0), ), ] ) return cal @pytest.fixture(name="calendar_mixed") def mock_calendar_mixed() -> Calendar: """Fixture with a mix of all day and datetime based events.""" cal = Calendar() cal.events.extend( [ Event( summary="All Day", start=datetime.date(2000, 2, 1), end=datetime.date(2000, 2, 2), ), Event( summary="Event @ 7 UTC", start=datetime.datetime( 2000, 2, 1, 7, 00, 0, tzinfo=datetime.timezone.utc ), end=datetime.datetime( 2000, 2, 2, 7, 00, 0, tzinfo=datetime.timezone.utc ), ), Event( summary="Event @ 5UTC", start=datetime.datetime( 2000, 2, 1, 5, 00, 0, tzinfo=datetime.timezone.utc ), end=datetime.datetime( 2000, 2, 2, 5, 00, 0, tzinfo=datetime.timezone.utc ), ), ] ) return cal def test_iteration(calendar: Calendar) -> None: """Test chronological iteration of a timeline.""" assert [e.summary for e in calendar.timeline] == [ "first", "second", "third", "fourth", ] @pytest.mark.parametrize( "when,expected_events", [ (datetime.date(2000, 1, 1), ["first"]), (datetime.date(2000, 2, 1), ["second"]), (datetime.date(2000, 3, 1), ["third"]), ], ) def test_on_date( calendar: Calendar, when: datetime.date, expected_events: list[str] ) -> None: """Test returning events on a particular day.""" assert [e.summary for e in calendar.timeline.on_date(when)] == expected_events def test_start_after(calendar: Calendar) -> None: """Test chronological iteration starting at a specific time.""" assert [ e.summary for e in calendar.timeline.start_after(datetime.date(2000, 1, 1)) ] == ["second", "third", "fourth"] assert [ e.summary for e in calendar.timeline.start_after(datetime.datetime(2000, 1, 1, 6, 0, 0)) ] == ["second", "third", "fourth"] assert [ e.summary for e in calendar.timeline.start_after(datetime.datetime(2000, 1, 15, 0, 0, 0)) ] == ["second", "third", "fourth"] def test_start_after_times(calendar_times: Calendar) -> None: """Test chronological iteration starting at a specific time.""" assert [ e.summary for e in calendar_times.timeline.start_after(datetime.date(2000, 1, 1)) ] == ["first", "second", "third"] assert [ e.summary for e in calendar_times.timeline.start_after( datetime.datetime(2000, 1, 1, 6, 0, 0) ) ] == ["first", "second", "third"] def test_active_after(calendar: Calendar) -> None: """Test chronological iteration starting at a specific time.""" assert [ e.summary for e in calendar.timeline.start_after(datetime.datetime(2000, 1, 1, 12, 0, 0)) ] == ["second", "third", "fourth"] assert [ e.summary for e in calendar.timeline.start_after(datetime.date(2000, 1, 1)) ] == ["second", "third", "fourth"] def test_active_after_times(calendar_times: Calendar) -> None: """Test chronological iteration starting at a specific time.""" assert [ e.summary for e in calendar_times.timeline.start_after( datetime.datetime(2000, 1, 1, 12, 0, 0) ) ] == ["third"] assert [ e.summary for e in calendar_times.timeline.start_after(datetime.date(2000, 1, 1)) ] == ["first", "second", "third"] @pytest.mark.parametrize( "at_datetime,expected_events", [ (datetime.datetime(2000, 1, 1, 11, 15), ["first"]), (datetime.datetime(2000, 1, 1, 11, 59), []), (datetime.datetime(2000, 1, 1, 12, 0), ["second"]), (datetime.datetime(2000, 1, 1, 12, 30), ["second"]), (datetime.datetime(2000, 1, 1, 12, 59), ["second"]), (datetime.datetime(2000, 1, 1, 13, 0), []), ], ) def test_at_instant( calendar_times: Calendar, at_datetime: datetime.datetime, expected_events: list[str] ) -> None: """Test returning events at a specific time.""" assert [ e.summary for e in calendar_times.timeline.at_instant(at_datetime) ] == expected_events @freeze_time("2000-01-01 12:30:00") def test_now(calendar_times: Calendar) -> None: """Test events happening at the current time.""" assert [e.summary for e in calendar_times.timeline.now()] == ["second"] @freeze_time("2000-01-01 13:00:00") def test_now_no_match(calendar_times: Calendar) -> None: """Test no events happening at the current time.""" assert [e.summary for e in calendar_times.timeline.now()] == [] @freeze_time("2000-01-01 12:30:00") def test_today(calendar_times: Calendar) -> None: """Test events active today.""" assert [e.summary for e in calendar_times.timeline.today()] == ["first", "second"] @pytest.mark.parametrize( "start,end,expected_events", [ ( datetime.datetime(2000, 1, 1, 10, 00), datetime.datetime(2000, 1, 2, 14, 00), ["first", "second", "third"], ), ( datetime.datetime(2000, 1, 1, 10, 00), datetime.datetime(2000, 1, 1, 14, 00), ["first", "second"], ), ( datetime.datetime(2000, 1, 1, 12, 00), datetime.datetime(2000, 1, 2, 14, 00), ["second", "third"], ), ( datetime.datetime(2000, 1, 1, 12, 00), datetime.datetime(2000, 1, 1, 14, 00), ["second"], ), ], ) def test_included( calendar_times: Calendar, start: datetime.datetime, end: datetime.datetime, expected_events: list[str], ) -> None: """Test calendar timeline inclusions.""" assert [ e.summary for e in calendar_times.timeline.included(start, end) ] == expected_events def test_multiple_calendars(calendar: Calendar, calendar_times: Calendar) -> None: """Test multiple calendars have independent event lists.""" assert len(calendar.events) == 4 assert len(calendar_times.events) == 3 assert len(Calendar().events) == 0 def test_multiple_iteration(calendar: Calendar) -> None: """Test iterating over a timeline multiple times.""" line = calendar.timeline assert [e.summary for e in line] == [ "first", "second", "third", "fourth", ] assert [e.summary for e in line] == [ "first", "second", "third", "fourth", ] TEST_ICS = """BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:19970610T172345Z UID:19970610T172345Z-AF23B2@example.com DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:Bastille Day Party END:VEVENT END:VCALENDAR""" def test_calendar_serialization() -> None: """Test single calendar serialization.""" calendar = IcsCalendarStream.calendar_from_ics(TEST_ICS) assert len(calendar.events) == 1 assert calendar.events[0].summary == "Bastille Day Party" output_ics = IcsCalendarStream.calendar_to_ics(calendar) assert output_ics == TEST_ICS def test_empty_calendar() -> None: """Test reading an empty calendar file.""" calendar = IcsCalendarStream.calendar_from_ics("") assert len(calendar.events) == 0 @pytest.fixture(name="_uid") def mock_uid() -> Generator[str, None, None]: """Patch out uuid creation with a fixed value.""" value = str(uuid.uuid3(uuid.NAMESPACE_DNS, "fixed-name")) with patch( "ical.event.uid_factory", return_value=value, ): yield value @freeze_time("2000-01-01 12:30:00") def test_create_and_serialize_calendar( _uid: str, mock_prodid: Generator[None, None, None], ) -> None: """Test creating a calendar manually then serializing.""" cal = Calendar() cal.events.append( Event( summary="Event", start=datetime.date(2000, 2, 1), end=datetime.date(2000, 2, 2), ) ) ics = IcsCalendarStream.calendar_to_ics(cal) assert re.split("\r?\n", ics) == [ "BEGIN:VCALENDAR", "PRODID:-//example//1.2.3", "VERSION:2.0", "BEGIN:VEVENT", "DTSTAMP:20000101T123000Z", "UID:68e9e07c-7557-36e2-91c1-8febe7527841", "DTSTART:20000201", "DTEND:20000202", "SUMMARY:Event", "END:VEVENT", "END:VCALENDAR", ] def test_mixed_iteration_order(calendar_mixed: Calendar) -> None: """Test iteration order of all day events based on the attendee timezone.""" # UTC order assert [e.summary for e in calendar_mixed.timeline_tz(datetime.timezone.utc)] == [ "All Day", "Event @ 5UTC", "Event @ 7 UTC", ] # Attendee is in -6 assert [ e.summary for e in calendar_mixed.timeline_tz(zoneinfo.ZoneInfo("America/Regina")) ] == ["Event @ 5UTC", "All Day", "Event @ 7 UTC"] # Attendee is in -8 assert [ e.summary for e in calendar_mixed.timeline_tz(zoneinfo.ZoneInfo("America/Los_Angeles")) ] == ["Event @ 5UTC", "Event @ 7 UTC", "All Day"] @pytest.mark.parametrize( "tzname,dt_before,dt_after", [ ( "America/Los_Angeles", # UTC-8 in Feb datetime.datetime(2000, 2, 1, 7, 59, 59, tzinfo=datetime.timezone.utc), datetime.datetime(2000, 2, 1, 8, 0, 0, tzinfo=datetime.timezone.utc), ), ( "America/Regina", # UTC-6 all year round datetime.datetime(2000, 2, 1, 5, 59, 59, tzinfo=datetime.timezone.utc), datetime.datetime(2000, 2, 1, 6, 0, 0, tzinfo=datetime.timezone.utc), ), ( "CET", # UTC-1 in Feb datetime.datetime(2000, 1, 31, 22, 59, 59, tzinfo=datetime.timezone.utc), datetime.datetime(2000, 1, 31, 23, 0, 0, tzinfo=datetime.timezone.utc), ), ], ) def test_all_day_with_local_timezone( tzname: str, dt_before: datetime.datetime, dt_after: datetime.datetime ) -> None: """Test iteration of all day events using local timezone override.""" cal = Calendar() cal.events.extend( [ Event( summary="event", start=datetime.date(2000, 2, 1), end=datetime.date(2000, 2, 2), ), ] ) local_tz = zoneinfo.ZoneInfo(tzname) def start_after(dtstart: datetime.datetime) -> list[str]: nonlocal cal, local_tz return [e.summary for e in cal.timeline_tz(local_tz).start_after(dtstart)] local_before = dt_before.astimezone(local_tz) assert start_after(local_before) == ["event"] local_after = dt_after.astimezone(local_tz) assert not start_after(local_after) @freeze_time(datetime.datetime(2023, 10, 25, 12, 0, 0, tzinfo=datetime.timezone.utc)) def test_floating_time_with_timezone_propagation() -> None: """Test iteration of floating time events ensuring timezones are respected.""" cal = Calendar() cal.events.extend( [ Event( summary="Event 1", start=datetime.datetime(2023, 10, 25, 6, 40), end=datetime.datetime(2023, 10, 25, 6, 50), rrule=Recur.from_rrule("FREQ=WEEKLY;BYDAY=WE,MO,TU,TH,FR"), ), Event( summary="Event 2", start=datetime.datetime(2023, 10, 25, 8, 30), end=datetime.datetime(2023, 10, 25, 8, 40), rrule=Recur.from_rrule("FREQ=DAILY"), ), Event( summary="Event 3", start=datetime.datetime(2023, 10, 25, 18, 30), end=datetime.datetime(2023, 10, 25, 18, 40), rrule=Recur.from_rrule("FREQ=DAILY"), ), ] ) # Ensure there are no calls to fetch the local timezone, only using the provided # timezone information. with patch("ical.util.local_timezone", side_effect=ValueError("do not invoke")): it = iter(cal.timeline_tz(zoneinfo.ZoneInfo("Europe/Brussels"))) for i in range(0, 30): next(it) allenporter-ical-fe8800b/tests/test_calendar_stream.py000066400000000000000000000136621510550726100232760ustar00rootroot00000000000000"""Tests for timeline related calendar events.""" from collections.abc import Generator import itertools import json import textwrap import pathlib import pytest from syrupy import SnapshotAssertion from ical.exceptions import CalendarParseError from ical.calendar_stream import CalendarStream, IcsCalendarStream from ical.store import TodoStore MAX_ITERATIONS = 30 TESTDATA_PATH = pathlib.Path("tests/testdata/") TESTDATA_FILES = list(TESTDATA_PATH.glob("*.ics")) TESTDATA_IDS = [x.stem for x in TESTDATA_FILES] def test_empty_ics(mock_prodid: Generator[None, None, None]) -> None: """Test serialization of an empty ics file.""" calendar = IcsCalendarStream.calendar_from_ics("") ics = IcsCalendarStream.calendar_to_ics(calendar) assert ics == textwrap.dedent( """\ BEGIN:VCALENDAR PRODID:-//example//1.2.3 VERSION:2.0 END:VCALENDAR""" ) calendar.prodid = "-//example//1.2.4" ics = IcsCalendarStream.calendar_to_ics(calendar) assert ics == textwrap.dedent( """\ BEGIN:VCALENDAR PRODID:-//example//1.2.4 VERSION:2.0 END:VCALENDAR""" ) @pytest.mark.parametrize("filename", TESTDATA_FILES, ids=TESTDATA_IDS) def test_parse( filename: pathlib.Path, snapshot: SnapshotAssertion, json_encoder: json.JSONEncoder ) -> None: """Fixture to read golden file and compare to golden output.""" cal = CalendarStream.from_ics(filename.read_text()) data = json.loads(cal.model_dump_json(exclude_unset=True, exclude_none=True)) assert snapshot == data # Re-parse the data object to verify we get the original data values # back. This effectively confirms that all fields can be parsed from the # python native format in addition to rfc5545. cal_reparsed = CalendarStream.model_validate(data) data_reparsed = json.loads( cal_reparsed.model_dump_json(exclude_unset=True, exclude_none=True) ) assert data_reparsed == data @pytest.mark.parametrize("filename", TESTDATA_FILES, ids=TESTDATA_IDS) def test_serialize(filename: pathlib.Path, snapshot: SnapshotAssertion) -> None: """Fixture to read golden file and compare to golden output.""" with filename.open() as f: cal = IcsCalendarStream.from_ics(f.read()) assert cal.ics() == snapshot @pytest.mark.parametrize("filename", TESTDATA_FILES, ids=TESTDATA_IDS) def test_timeline_iteration(filename: pathlib.Path) -> None: """Fixture to ensure all calendar events are valid and support iteration.""" with filename.open() as f: cal = IcsCalendarStream.from_ics(f.read()) for calendar in cal.calendars: # Iterate over the timeline to ensure events are valid. There is a max # to handle recurring events that may repeat forever. for event in itertools.islice(calendar.timeline, MAX_ITERATIONS): assert event is not None @pytest.mark.parametrize("filename", TESTDATA_FILES, ids=TESTDATA_IDS) def test_todo_list_iteration(filename: pathlib.Path) -> None: """Fixture to read golden file and compare to golden output.""" cal = CalendarStream.from_ics(filename.read_text()) if not cal.calendars: return calendar = cal.calendars[0] tl = TodoStore(calendar).todo_list() for todo in itertools.islice(tl, MAX_ITERATIONS): assert todo is not None @pytest.mark.parametrize( "content", [ textwrap.dedent( """\ invalid """ ), textwrap.dedent( """\ BEGIN:VCALENDAR VERSION:\x007 END:VCALENDAR """ ), textwrap.dedent( """\ BEGIN:VCALENDAR PROD\uc27fID://example END:VCALENDAR """ ), textwrap.dedent( """\ BEGIN:VCALENDAR ATTENDEE;MEM\x007ER="mailto:DEV-GROUP@example.com":mailto:joecool@example.com END:VCALENDAR """ ), textwrap.dedent( """\ BEGIN:VCALENDAR ATTENDEE;MEMBER="mailto:DEV-GROUP\x00example.com":mailto:joecool@example.com END:VCALENDAR """ ), ], ids=[ "invalid", "control-char-value", "control-char-name", "control-param-name", "control-param-value", ], ) def test_invalid_ics(content: str) -> None: """Test parsing failures for ics content. These are tested here so we can add escape sequences. Most other invalid encodings are tested in the yaml testdata/ files. """ with pytest.raises( CalendarParseError, match="^Calendar contents are not valid ICS format, see the detailed_error for more information$", ): IcsCalendarStream.calendar_from_ics(content) def test_component_failure() -> None: with pytest.raises( CalendarParseError, match="^Failed to parse calendar EVENT component: Value error, Unexpected dtstart value '2022-07-24 12:00:00' was datetime but dtend value '2022-07-24' was not datetime$", ): IcsCalendarStream.calendar_from_ics( textwrap.dedent( """\ BEGIN:VCALENDAR PRODID:-//example//1.2.3 VERSION:2.0 BEGIN:VEVENT DTSTART:20220724T120000 DTEND:20220724 END:VEVENT END:VCALENDAR """ ) ) def test_multiple_calendars() -> None: with pytest.raises(CalendarParseError, match="more than one calendar"): IcsCalendarStream.calendar_from_ics( textwrap.dedent( """\ BEGIN:VCALENDAR PRODID:-//example//1.2.3 VERSION:2.0 END:VCALENDAR BEGIN:VCALENDAR PRODID:-//example//1.2.3 VERSION:2.0 END:VCALENDAR """ ) ) allenporter-ical-fe8800b/tests/test_component.py000066400000000000000000000146121510550726100221500ustar00rootroot00000000000000"""Tests for component encoding and decoding.""" from pydantic import field_serializer import pytest import datetime import zoneinfo from typing import Optional, Union from ical.component import ComponentModel from ical.exceptions import CalendarParseError, ParameterValueError from ical.parsing.component import ParsedComponent from ical.parsing.property import ParsedProperty, ParsedPropertyParameter from ical.types.data_types import serialize_field def test_encode_component() -> None: """Test for a text property value.""" class OtherComponent(ComponentModel): """Model used as a sub-component.""" other_value: str second_value: Optional[str] = None class TestModel(ComponentModel): """Model with a Text value.""" text_value: str repeated_text_value: list[str] some_component: list[OtherComponent] single_component: OtherComponent dt: datetime.datetime serialize_fields = field_serializer("*")(serialize_field) # type: ignore[pydantic-field] model = TestModel.model_validate( { "text_value": "Example text", "repeated_text_value": ["a", "b", "c"], "some_component": [ {"other_value": "value1", "second_value": "valuez"}, {"other_value": "value2"}, ], "single_component": { "other_value": "value3", }, "dt": [ParsedProperty(name="dt", value="20220724T120000")], } ) component = model.__encode_component_root__() assert component.name == "TestModel" assert component.properties == [ ParsedProperty(name="text_value", value="Example text"), ParsedProperty(name="repeated_text_value", value="a"), ParsedProperty(name="repeated_text_value", value="b"), ParsedProperty(name="repeated_text_value", value="c"), ParsedProperty(name="dt", value="20220724T120000"), ] assert component.components == [ ParsedComponent( name="some_component", properties=[ ParsedProperty(name="other_value", value="value1"), ParsedProperty(name="second_value", value="valuez"), ], ), ParsedComponent( name="some_component", properties=[ ParsedProperty(name="other_value", value="value2"), ], ), ParsedComponent( name="single_component", properties=[ ParsedProperty(name="other_value", value="value3"), ], ), ] def test_list_parser() -> None: """Test for a repeated property value.""" class TestModel(ComponentModel): """Model under test.""" dt: list[datetime.datetime] model = TestModel.model_validate( { "dt": [ ParsedProperty(name="dt", value="20220724T120000"), ParsedProperty(name="dt", value="20220725T130000"), ], } ) assert model.dt == [ datetime.datetime(2022, 7, 24, 12, 0, 0), datetime.datetime(2022, 7, 25, 13, 0, 0), ] def test_list_union_parser() -> None: """Test for a repeated union value.""" class TestModel(ComponentModel): """Model under test.""" dt: list[Union[datetime.datetime, datetime.date]] model = TestModel.model_validate( { "dt": [ ParsedProperty(name="dt", value="20220724T120000"), ParsedProperty(name="dt", value="20220725"), ], } ) assert model.dt == [ datetime.datetime(2022, 7, 24, 12, 0, 0), datetime.date(2022, 7, 25), ] def test_optional_field_parser() -> None: """Test for an optional field parser.""" class TestModel(ComponentModel): """Model under test.""" dt: Optional[datetime.datetime] = None model = TestModel.model_validate( {"dt": [ParsedProperty(name="dt", value="20220724T120000")]} ) assert model.dt == datetime.datetime(2022, 7, 24, 12, 0, 0) def test_union_parser() -> None: """Test for a union value.""" class TestModel(ComponentModel): """Model under test.""" dt: Union[datetime.datetime, datetime.date] with pytest.raises(CalendarParseError, match=".*Expected one value for field: dt"): model = TestModel.model_validate( { "dt": [ ParsedProperty(name="dt", value="20220724T120000"), ParsedProperty(name="dt", value="20220725"), ], }, ) model = TestModel.model_validate( { "dt": [ ParsedProperty(name="dt", value="20220724T120000"), ], } ) assert model.dt == datetime.datetime(2022, 7, 24, 12, 0, 0) model = TestModel.model_validate( { "dt": [ ParsedProperty(name="dt", value="20220725"), ], } ) assert model.dt == datetime.date(2022, 7, 25) model = TestModel.model_validate( { "dt": [ ParsedProperty( name="dt", value="20220724T120000", params=[ParsedPropertyParameter("TZID", ["America/New_York"])], ), ], } ) assert model.dt == datetime.datetime( 2022, 7, 24, 12, 0, 0, tzinfo=zoneinfo.ZoneInfo(key="America/New_York") ) assert model.dt != datetime.datetime(2022, 7, 24, 12, 0, 0) with pytest.raises( CalendarParseError, match="Expected DATE-TIME TZID value 'America/New_Mork' to be valid timezone.*", ): model = TestModel.model_validate( { "dt": [ ParsedProperty( name="dt", value="20220724T120000", params=[ParsedPropertyParameter("TZID", ["America/New_Mork"])], ), ], } ) with pytest.raises( CalendarParseError, match=".*Failed to validate: .* as datetime or date, due to: .*Expected value to match DATE-TIME pattern: .*Expected value to match DATE pattern: .*", ): model = TestModel.model_validate( { "dt": [ ParsedProperty(name="dt", value="2025NotADateOrADateTime"), ], } ) allenporter-ical-fe8800b/tests/test_diagnostics.py000066400000000000000000000013401510550726100224470ustar00rootroot00000000000000"""Tests for diagnostics.""" import pathlib import pytest from ical.diagnostics import redact_ics from syrupy import SnapshotAssertion def test_empty() -> None: """Test redaction of an empty ics file.""" assert list(redact_ics("")) == [] assert list(redact_ics("\n")) == [] @pytest.mark.parametrize( ("filename"), [ ("tests/testdata/datetime_timezone.ics"), ("tests/testdata/description_altrep.ics"), ], ids=("datetime_timezone", "description_altrep"), ) def test_redact_date_timezone(filename: str, snapshot: SnapshotAssertion) -> None: """Test redaction of an empty ics file.""" ics = pathlib.Path(filename).read_text() assert "\n".join(list(redact_ics(ics))) == snapshot allenporter-ical-fe8800b/tests/test_event.py000066400000000000000000000345531510550726100212750ustar00rootroot00000000000000"""Tests for Event component.""" from __future__ import annotations from typing import Any from datetime import date, datetime, timedelta, timezone from unittest.mock import patch import zoneinfo import pytest from pydantic import ValidationError from ical.event import Event from ical.exceptions import CalendarParseError from ical.types.recur import Recur SUMMARY = "test summary" LOS_ANGELES = zoneinfo.ZoneInfo("America/Los_Angeles") @pytest.mark.parametrize( "begin,end,duration", [ ( datetime.fromisoformat("2022-09-16 12:00"), datetime.fromisoformat("2022-09-16 12:30"), timedelta(minutes=30), ), ( date.fromisoformat("2022-09-16"), date.fromisoformat("2022-09-17"), timedelta(hours=24), ), ( datetime.fromisoformat("2022-09-16 06:00"), datetime.fromisoformat("2022-09-17 08:30"), timedelta(days=1, hours=2, minutes=30), ), ], ) def test_start_end_duration( begin: datetime, end: datetime, duration: timedelta ) -> None: """Test event duration calculation.""" event = Event(summary=SUMMARY, start=begin, end=end) assert event.computed_duration == duration assert not event.duration @pytest.mark.parametrize( "event1_start,event1_end,event2_start,event2_end", [ (date(2022, 9, 6), date(2022, 9, 7), date(2022, 9, 8), date(2022, 9, 10)), ( datetime(2022, 9, 6, 6, 0, 0), datetime(2022, 9, 6, 7, 0, 0), datetime(2022, 9, 6, 8, 0, 0), datetime(2022, 9, 6, 8, 30, 0), ), ( datetime(2022, 9, 6, 6, 0, 0, tzinfo=timezone.utc), datetime(2022, 9, 6, 7, 0, 0, tzinfo=timezone.utc), datetime(2022, 9, 6, 8, 0, 0, tzinfo=timezone.utc), datetime(2022, 9, 6, 8, 30, 0, tzinfo=timezone.utc), ), ( datetime(2022, 9, 6, 6, 0, 0, tzinfo=LOS_ANGELES), datetime(2022, 9, 6, 7, 0, 0, tzinfo=LOS_ANGELES), datetime(2022, 9, 7, 8, 0, 0, tzinfo=timezone.utc), datetime(2022, 9, 7, 8, 30, 0, tzinfo=timezone.utc), ), ( datetime(2022, 9, 6, 6, 0, 0, tzinfo=LOS_ANGELES), datetime(2022, 9, 6, 7, 0, 0, tzinfo=LOS_ANGELES), datetime(2022, 9, 8, 8, 0, 0), datetime(2022, 9, 8, 8, 30, 0), ), ( datetime(2022, 9, 6, 6, 0, 0, tzinfo=LOS_ANGELES), datetime(2022, 9, 6, 7, 0, 0, tzinfo=LOS_ANGELES), date(2022, 9, 8), date(2022, 9, 9), ), ( date(2022, 9, 6), date(2022, 9, 7), datetime(2022, 9, 6, 8, 0, 1, tzinfo=timezone.utc), datetime(2022, 9, 6, 8, 30, 0, tzinfo=timezone.utc), ), ], ) def test_comparisons( event1_start: datetime | date, event1_end: datetime | date, event2_start: datetime | date, event2_end: datetime | date, ) -> None: """Test event comparison methods.""" event1 = Event(summary=SUMMARY, start=event1_start, end=event1_end) event2 = Event(summary=SUMMARY, start=event2_start, end=event2_end) assert event1 < event2 assert event1 <= event2 assert event2 >= event1 assert event2 > event1 assert event1 <= event2 assert event2 >= event1 assert event2 > event1 def test_invalid_comparisons() -> None: """Test event comparisons that are not valid.""" event1 = Event(summary=SUMMARY, start=date(2022, 9, 6), end=date(2022, 9, 7)) with pytest.raises(TypeError): assert event1 < "example" with pytest.raises(TypeError): assert event1 <= "example" with pytest.raises(TypeError): assert event1 > "example" with pytest.raises(TypeError): assert event1 >= "example" def test_within_and_includes() -> None: """Test more complex comparison methods.""" event1 = Event(summary=SUMMARY, start=date(2022, 9, 6), end=date(2022, 9, 10)) event2 = Event(summary=SUMMARY, start=date(2022, 9, 7), end=date(2022, 9, 8)) event3 = Event(summary=SUMMARY, start=date(2022, 9, 9), end=date(2022, 9, 11)) assert not event1.starts_within(event2) assert not event1.starts_within(event3) assert event2.starts_within(event1) assert not event2.starts_within(event3) assert event3.starts_within(event1) assert not event3.starts_within(event2) assert not event1.ends_within(event2) assert event1.ends_within(event3) assert event2.ends_within(event1) assert not event2.ends_within(event3) assert not event3.ends_within(event1) assert not event3.ends_within(event2) assert event2 > event1 assert event1.includes(event2) assert not event1.includes(event3) assert not event2.includes(event1) assert not event2.includes(event3) assert not event3.includes(event1) assert not event3.includes(event2) assert not event1.is_included_in(event2) assert not event1.is_included_in(event3) assert event2.is_included_in(event1) assert not event2.is_included_in(event3) assert not event3.is_included_in(event1) assert not event3.is_included_in(event2) def test_start_end_same_type() -> None: """Verify that the start and end value are the same type.""" with pytest.raises(CalendarParseError): Event( summary=SUMMARY, start=date(2022, 9, 9), end=datetime(2022, 9, 9, 11, 0, 0) ) with pytest.raises(CalendarParseError): Event( summary=SUMMARY, start=datetime(2022, 9, 9, 10, 0, 0), end=date(2022, 9, 9) ) def test_no_end_time_or_dur() -> None: """Verify that events with no end time or duration will use correct defaults.""" day_event = Event(summary=SUMMARY, dtstart=date(2022, 9, 9)) assert day_event.end == date(2022, 9, 10) assert day_event.duration is None assert day_event.computed_duration == timedelta(days=1) time_event = Event(summary=SUMMARY, dtstart=datetime(2022, 9, 9, 10, 0, 0)) assert time_event.end == datetime(2022, 9, 9, 10, 0, 0) assert time_event.duration is None assert time_event.computed_duration == timedelta() def test_start_end_local_time() -> None: """Verify that the start and end value are the same type.""" # Valid Event( summary=SUMMARY, start=datetime(2022, 9, 9, 10, 0, 0), end=datetime(2022, 9, 9, 11, 0, 0), ) Event( summary=SUMMARY, start=datetime(2022, 9, 9, 10, 0, 0, tzinfo=timezone.utc), end=datetime(2022, 9, 9, 11, 0, 0, tzinfo=timezone.utc), ) with pytest.raises(CalendarParseError): Event( summary=SUMMARY, start=datetime(2022, 9, 9, 10, 0, 0, tzinfo=timezone.utc), end=datetime(2022, 9, 9, 11, 0, 0), ) with pytest.raises(CalendarParseError): Event( summary=SUMMARY, start=datetime(2022, 9, 9, 10, 0, 0), end=datetime(2022, 9, 9, 11, 0, 0, tzinfo=timezone.utc), ) def test_start_and_duration() -> None: """Verify event created with a duration instead of explicit end time.""" event = Event(summary=SUMMARY, start=date(2022, 9, 9), duration=timedelta(days=3)) assert event.start == date(2022, 9, 9) assert event.end == date(2022, 9, 12) with pytest.raises(CalendarParseError): Event(summary=SUMMARY, start=date(2022, 9, 9), duration=timedelta(days=-3)) with pytest.raises(CalendarParseError): Event(summary=SUMMARY, start=date(2022, 9, 9), duration=timedelta(seconds=60)) event = Event( summary=SUMMARY, start=datetime(2022, 9, 9, 10, 0, 0), duration=timedelta(seconds=60), ) assert event.start == datetime(2022, 9, 9, 10, 0, 0) assert event.end == datetime(2022, 9, 9, 10, 1, 0) assert event.duration == timedelta(seconds=60) assert event.computed_duration == timedelta(seconds=60) @pytest.mark.parametrize( "range1,range2,expected", [ ( (date(2022, 8, 1), date(2022, 8, 2)), (date(2022, 8, 1), date(2022, 8, 2)), True, ), ( (date(2022, 8, 1), date(2022, 8, 4)), (date(2022, 8, 2), date(2022, 8, 3)), True, ), ( (date(2022, 8, 1), date(2022, 8, 3)), (date(2022, 8, 2), date(2022, 8, 4)), True, ), ( (date(2022, 8, 2), date(2022, 8, 3)), (date(2022, 8, 1), date(2022, 8, 4)), True, ), ( (date(2022, 8, 3), date(2022, 8, 4)), (date(2022, 8, 1), date(2022, 8, 4)), True, ), ( (date(2022, 8, 2), date(2022, 8, 4)), (date(2022, 8, 1), date(2022, 8, 4)), True, ), ( (date(2022, 8, 1), date(2022, 8, 2)), (date(2022, 8, 3), date(2022, 8, 4)), False, ), ( (date(2022, 8, 3), date(2022, 8, 4)), (date(2022, 8, 1), date(2022, 8, 2)), False, ), ( (date(2022, 8, 1), date(2022, 8, 2)), (date(2022, 8, 2), date(2022, 8, 3)), False, ), ( (date(2022, 8, 2), date(2022, 8, 3)), (date(2022, 8, 1), date(2022, 8, 2)), False, ), ], ) def test_date_intersects( range1: tuple[date, date], range2: tuple[date, date], expected: bool, ) -> None: """Test event intersection.""" event1 = Event(summary=SUMMARY, start=range1[0], end=range1[1]) event2 = Event(summary=SUMMARY, start=range2[0], end=range2[1]) assert event1.intersects(event2) == expected @pytest.mark.parametrize( "start_str,end_str,start,end", [ ( "2022-09-16 12:00", "2022-09-16 12:30", datetime(2022, 9, 16, 12, 0, 0), datetime(2022, 9, 16, 12, 30, 0), ), ( "2022-09-16", "2022-09-17", date(2022, 9, 16), date(2022, 9, 17), ), ( "2022-09-16 06:00", "2022-09-17 08:30", datetime(2022, 9, 16, 6, 0, 0), datetime(2022, 9, 17, 8, 30, 0), ), ( "2022-09-16T06:00", "2022-09-17T08:30", datetime(2022, 9, 16, 6, 0, 0), datetime(2022, 9, 17, 8, 30, 0), ), ( "2022-09-16T06:00Z", "2022-09-17T08:30Z", datetime(2022, 9, 16, 6, 0, 0, tzinfo=timezone.utc), datetime(2022, 9, 17, 8, 30, 0, tzinfo=timezone.utc), ), ( "2022-09-16T06:00+00:00", "2022-09-17T08:30+00:00", datetime(2022, 9, 16, 6, 0, 0, tzinfo=timezone.utc), datetime(2022, 9, 17, 8, 30, 0, tzinfo=timezone.utc), ), ( "2022-09-16T06:00-07:00", "2022-09-17T08:30-07:00", datetime(2022, 9, 16, 6, 0, 0, tzinfo=timezone(offset=timedelta(hours=-7))), datetime( 2022, 9, 17, 8, 30, 0, tzinfo=timezone(offset=timedelta(hours=-7)) ), ), ], ) def test_parse_event_timezones( start_str: str, end_str: str, start: datetime | date, end: datetime | date ) -> None: """Test parsing date/times from strings.""" event = Event.model_validate( { "summary": SUMMARY, "start": start_str, "end": end_str, } ) assert event.start == start assert event.end == end def test_all_day_timezones_default() -> None: """Test behavior of all day events interacting with timezones.""" with patch( "ical.util.local_timezone", return_value=zoneinfo.ZoneInfo("America/Regina") ): event = Event(summary=SUMMARY, start=date(2022, 8, 1), end=date(2022, 8, 2)) assert event.start_datetime == datetime( 2022, 8, 1, 6, 0, 0, tzinfo=timezone.utc ) assert event.end_datetime == datetime(2022, 8, 2, 6, 0, 0, tzinfo=timezone.utc) @pytest.mark.parametrize( "dtstart,dtend", [ ( datetime(2022, 8, 1, 0, 0, 0, tzinfo=zoneinfo.ZoneInfo("America/Regina")), datetime(2022, 8, 2, 0, 0, 0, tzinfo=zoneinfo.ZoneInfo("America/Regina")), ), ( datetime( 2022, 8, 1, 0, 0, 0, tzinfo=zoneinfo.ZoneInfo("America/Los_Angeles") ), datetime( 2022, 8, 2, 0, 0, 0, tzinfo=zoneinfo.ZoneInfo("America/Los_Angeles") ), ), ], ) def test_all_day_timespan_timezone_explicit(dtstart: datetime, dtend: datetime) -> None: """Test behavior of all day events interacting with timezones specified explicitly.""" event = Event(summary=SUMMARY, start=date(2022, 8, 1), end=date(2022, 8, 2)) assert dtstart.tzinfo timespan = event.timespan_of(dtstart.tzinfo) assert timespan.start == dtstart assert timespan.end == dtend def test_validate_assignment_1() -> None: """Test date type validations.""" event = Event(summary=SUMMARY, start=date(2022, 9, 6), end=date(2022, 9, 7)) # Validation on assignment ensures the start/end types can't be mismatched with pytest.raises(ValidationError): event.dtstart = datetime(2022, 9, 6, 6, 0, 0) def test_validate_assignment_2() -> None: """Test date type validations.""" event = Event(summary=SUMMARY, start=date(2022, 9, 6), end=date(2022, 9, 7)) # Validation on assignment ensures the start/end types can't be mismatched with pytest.raises(ValidationError): event.dtend = datetime(2022, 9, 10, 6, 0, 0) def test_validate_assignment_3() -> None: """Test date type validations.""" event = Event(summary=SUMMARY, start=date(2022, 9, 6), end=date(2022, 9, 7)) # But updates that are valid are OK event.dtstart = date(2022, 9, 5) event.dtend = date(2022, 9, 10) @pytest.mark.parametrize( ("params"), [ ({}), ( { "end": datetime(2022, 9, 6, 6, 0, 0), } ), ( { "duration": timedelta(hours=1), } ), ], ) def test_validate_rrule_required_fields(params: dict[str, Any]) -> None: """Test that an event with an rrule requires a dtstart.""" event = Event( summary="Event 1", rrule=Recur.from_rrule("FREQ=WEEKLY;BYDAY=WE,MO,TU,TH,FR;COUNT=3"), **params, ) with pytest.raises(CalendarParseError): event.as_rrule() allenporter-ical-fe8800b/tests/test_freebusy.py000066400000000000000000000077751510550726100220060ustar00rootroot00000000000000"""Tests for Free/Busy component.""" from __future__ import annotations import datetime import zoneinfo from typing import Generator from unittest.mock import patch import pytest from ical.exceptions import CalendarParseError from ical.freebusy import FreeBusy from ical.types import FreeBusyType, Period @pytest.fixture(autouse=True) def local_timezone() -> Generator[None, None, None]: """Fixture to set a local timezone to use during tests.""" with patch( "ical.util.local_timezone", return_value=zoneinfo.ZoneInfo("America/Regina") ): yield def test_empty() -> None: """Test that no fields are required.""" freebusy = FreeBusy() assert not freebusy.sequence assert not freebusy.start assert not freebusy.start_datetime assert not freebusy.end assert not freebusy.end_datetime assert not freebusy.computed_duration def test_freebusy() -> None: """Test a valid Journal object.""" freebusy = FreeBusy(sequence=1) assert freebusy.sequence == 1 def test_start_datetime() -> None: """Test FreeBusy with a start datetime and no end.""" freebusy = FreeBusy( start=datetime.datetime(2022, 8, 7, 5, 0, 0, tzinfo=datetime.timezone.utc) ) assert freebusy.start assert freebusy.start.isoformat() == "2022-08-07T05:00:00+00:00" assert freebusy.start_datetime assert freebusy.start_datetime.isoformat() == "2022-08-07T05:00:00+00:00" assert not freebusy.end assert not freebusy.end_datetime def test_start_date() -> None: """Test FreeBusy with a start date and no end.""" freebusy = FreeBusy(start=datetime.date(2022, 8, 7)) assert freebusy.start.isoformat() == "2022-08-07" # Use local timezone assert freebusy.start_datetime assert freebusy.start_datetime.isoformat() == "2022-08-07T06:00:00+00:00" assert not freebusy.end assert not freebusy.end_datetime def test_start_end_date() -> None: """Test freebusy start date conversions.""" freebusy = FreeBusy(start=datetime.date(2022, 8, 7), end=datetime.date(2022, 8, 10)) assert freebusy.start assert freebusy.start.isoformat() == "2022-08-07" assert freebusy.end assert freebusy.end.isoformat() == "2022-08-10" assert freebusy.computed_duration == datetime.timedelta(days=3) # Use local timezone assert freebusy.start_datetime assert freebusy.start_datetime.isoformat() == "2022-08-07T06:00:00+00:00" assert freebusy.end_datetime assert freebusy.end_datetime.isoformat() == "2022-08-10T06:00:00+00:00" def test_free_busy() -> None: """Test freebusy start date conversions.""" freebusy = FreeBusy( start=datetime.date(2022, 8, 7), end=datetime.date(2022, 8, 10), freebusy=[ Period( start=datetime.datetime( 2022, 8, 7, 5, 0, 0, tzinfo=datetime.timezone.utc ), duration=datetime.timedelta(hours=2), free_busy_type=FreeBusyType.BUSY, ), Period( start=datetime.datetime( 2022, 8, 7, 10, 0, 0, tzinfo=datetime.timezone.utc ), duration=datetime.timedelta(minutes=30), free_busy_type=FreeBusyType.BUSY, ), ], ) assert freebusy.start assert freebusy.start.isoformat() == "2022-08-07" assert freebusy.end assert freebusy.end.isoformat() == "2022-08-10" assert len(freebusy.freebusy) == 2 def test_free_busy_requires_utc() -> None: """Test freebusy start date conversions.""" with pytest.raises( CalendarParseError, match=r"Freebusy time must be in UTC format.*" ): FreeBusy( start=datetime.date(2022, 8, 7), end=datetime.date(2022, 8, 10), freebusy=[ Period( start=datetime.datetime(2022, 8, 7, 5, 0, 0), duration=datetime.timedelta(hours=2), free_busy_type=FreeBusyType.BUSY, ), ], ) allenporter-ical-fe8800b/tests/test_iter.py000066400000000000000000000104221510550726100211040ustar00rootroot00000000000000"""Tests for the iter library.""" from __future__ import annotations import datetime import random from collections.abc import Generator from typing import Any, Iterable, Iterator import pytest from dateutil import rrule from ical.iter import ( MergedIterable, MergedIterator, RecurIterable, RecurrenceError, RulesetIterable, ) EMPTY_LIST: list[bool] = [] EMPTY_ITERATOR_LIST: list[Iterator[bool]] = [] EMPTY_ITERABLE_LIST: list[Iterable[bool]] = [] def test_merged_empty() -> None: """Test iterating over an empty input.""" with pytest.raises(StopIteration): next(iter(MergedIterable(EMPTY_ITERABLE_LIST))) with pytest.raises(StopIteration): next(iter(MergedIterator(EMPTY_ITERATOR_LIST))) with pytest.raises(StopIteration): next(MergedIterator(EMPTY_ITERATOR_LIST)) with pytest.raises(StopIteration): next(MergedIterator([iter(EMPTY_LIST), iter(EMPTY_LIST)])) def test_merge_is_sorted() -> None: """Test that the merge result of two sorted inputs is sorted.""" merged_it = MergedIterable([[1, 3, 5], [2, 4, 6]]) assert list(merged_it) == [1, 2, 3, 4, 5, 6] def test_recur_empty() -> None: """Test recur with an empty input.""" def _is_even_year(value: datetime.date) -> bool: return value.year % 2 == 0 input_dates = [ datetime.date(2022, 1, 1), datetime.date(2023, 1, 1), datetime.date(2024, 1, 1), ] recur_it = RecurIterable(_is_even_year, input_dates) assert list(recur_it) == [True, False, True] # an iterator is an iterable assert list(iter(iter(recur_it))) == [True, False, True] def test_merge_false_values() -> None: """Test the merged iterator can handle values evaluating to False.""" merged_it: Iterable[float | int] = MergedIterable([[0, 1], [-2, 0, 0.5, 2]]) assert list(merged_it) == [-2, 0, 0, 0.5, 1, 2] def test_merge_none_values() -> None: """Test the merged iterator can handle None values.""" merged_it = MergedIterable([[None, None], [None]]) assert list(merged_it) == [None, None, None] @pytest.mark.parametrize( "num_iters,num_objects", [ (10, 10), (10, 100), (10, 1000), (100, 10), (100, 100), ], ) def test_benchmark_merged_iter( num_iters: int, num_objects: int, benchmark: Any ) -> None: """Add a benchmark for the merged iterator.""" def gen_items() -> Generator[float, None, None]: nonlocal num_objects for _ in range(num_objects): yield random.random() def exhaust() -> int: nonlocal num_iters merged_it = MergedIterable([gen_items() for _ in range(num_iters)]) return sum(1 for _ in merged_it) result = benchmark(exhaust) assert result == num_iters * num_objects def test_debug_invalid_rules() -> None: """Exercise debug information by creating rules not supported by dateutil.""" recur_iter = RulesetIterable( datetime.datetime(2022, 12, 19, 5, 0, 0), [ rrule.rrule( freq=rrule.DAILY, dtstart=datetime.datetime(2022, 12, 19, 5, 0, 0), count=3, ) ], [datetime.date(2022, 12, 22)], [datetime.date(2022, 12, 23)], ) with pytest.raises(RecurrenceError) as exc_info: list(recur_iter) assert exc_info.value.args[0].startswith( "Error evaluating recurrence rule (RulesetIterable(dtstart=2022-12-19 05:00:00, " "rrule=['DTSTART:20221219T050000\\nRRULE:FREQ=DAILY;COUNT=3'], " "rdate=[datetime.date(2022, 12, 22)], " "exdate=[datetime.date(2022, 12, 23)]))" ) def test_debug_invalid_rule_without_recur() -> None: """Test debugging information for another variation of unsupported ruleset.""" recur_iter = RulesetIterable( datetime.datetime(2022, 12, 19, 5, 0, 0), [], [datetime.date(2022, 12, 22)], [datetime.datetime(2022, 12, 23, 5, 0, 0)], ) with pytest.raises(RecurrenceError) as exc_info: list(recur_iter) assert exc_info.value.args[0].startswith( "Error evaluating recurrence rule (RulesetIterable(dtstart=2022-12-19 05:00:00, " "rrule=[], " "rdate=[datetime.date(2022, 12, 22)], " "exdate=[datetime.datetime(2022, 12, 23, 5, 0)]))" ) allenporter-ical-fe8800b/tests/test_journal.py000066400000000000000000000044151510550726100216200ustar00rootroot00000000000000"""Tests for Journal component.""" from __future__ import annotations import datetime import zoneinfo from unittest.mock import patch import pytest from ical.exceptions import CalendarParseError from ical.journal import Journal, JournalStatus from ical.timespan import Timespan def test_empty() -> None: """Test that in practice a Journal requires no fields.""" journal = Journal() assert not journal.summary def test_journal() -> None: """Test a valid Journal object.""" journal = Journal(summary="Example") assert journal.summary == "Example" def test_status() -> None: """Test Journal status.""" journal = Journal.model_validate({"status": "DRAFT"}) assert journal.status == JournalStatus.DRAFT with pytest.raises( CalendarParseError, match="^Failed to parse calendar JOURNAL component: Input should be 'DRAFT', 'FINAL' or 'CANCELLED'$", ): Journal.model_validate({"status": "invalid-status"}) def test_start_datetime() -> None: """Test journal start date conversions.""" journal = Journal(start=datetime.date(2022, 8, 7)) assert journal.start assert journal.start.isoformat() == "2022-08-07" with ( patch( "ical.util.local_timezone", return_value=zoneinfo.ZoneInfo("America/Regina") ), patch( "ical.journal.local_timezone", return_value=zoneinfo.ZoneInfo("America/Regina"), ), ): assert journal.start_datetime.isoformat() == "2022-08-07T06:00:00+00:00" assert not journal.recurring ts = journal.timespan assert ts assert ts.start.isoformat() == "2022-08-07T00:00:00-06:00" assert ts.end.isoformat() == "2022-08-08T00:00:00-06:00" def test_computed_duration_date() -> None: """Test computed duration for a date.""" journal = Journal( start=datetime.date( 2022, 8, 7, ) ) assert journal.start assert journal.computed_duration == datetime.timedelta(days=1) def test_computed_duration_datetime() -> None: """Test computed duration for a datetime.""" journal = Journal(start=datetime.datetime(2022, 8, 7, 0, 0, 0)) assert journal.start assert journal.computed_duration == datetime.timedelta(hours=1) allenporter-ical-fe8800b/tests/test_list.py000066400000000000000000000063011510550726100211150ustar00rootroot00000000000000"""Tests for list view of todo items.""" import datetime import freezegun import pytest from ical.list import todo_list_view from ical.todo import Todo from ical.types.recur import Recur def test_empty_list() -> None: """Test an empty list.""" view = todo_list_view([]) assert list(view) == [] @pytest.mark.parametrize( ("status"), [ ("NEEDS-ACTION"), ("IN-PROCESS"), ], ) def test_daily_recurring_item_due_today_incomplete(status: str) -> None: """Test a daily recurring item that is due today .""" with freezegun.freeze_time("2024-01-10T10:05:00-05:00"): todo = Todo( dtstart=datetime.date.today() - datetime.timedelta(days=1), summary="Daily incomplete", due=datetime.date.today(), rrule=Recur.from_rrule("FREQ=DAILY"), status=status, ) view = list(todo_list_view([todo])) assert len(view) == 1 assert view[0].summary == todo.summary assert view[0].dtstart == datetime.date(2024, 1, 10) assert view[0].due == datetime.date(2024, 1, 11) assert view[0].recurrence_id == "20240110" @pytest.mark.parametrize( ("status"), [ ("NEEDS-ACTION"), ("IN-PROCESS"), ], ) def test_daily_recurring_item_due_tomorrow(status: str) -> None: """Test a daily recurring item that is due tomorrow.""" with freezegun.freeze_time("2024-01-10T10:05:00-05:00"): todo = Todo( dtstart=datetime.date.today(), summary="Daily incomplete", due=datetime.date.today() + datetime.timedelta(days=1), rrule=Recur.from_rrule("FREQ=DAILY"), status=status, ) view = list(todo_list_view([todo])) assert len(view) == 1 assert view[0].summary == todo.summary assert view[0].dtstart == datetime.date(2024, 1, 10) assert view[0].due == datetime.date(2024, 1, 11) assert view[0].recurrence_id == "20240110" @pytest.mark.parametrize( ("status"), [ ("NEEDS-ACTION"), ("IN-PROCESS"), ], ) def test_daily_recurring_item_due_yesterday(status: str) -> None: """Test a daily recurring item that is due yesterday .""" with freezegun.freeze_time("2024-01-10T10:05:00-05:00"): todo = Todo( dtstart=datetime.date.today() - datetime.timedelta(days=1), summary="Daily incomplete", due=datetime.date.today(), rrule=Recur.from_rrule("FREQ=DAILY"), status=status, ) view = list(todo_list_view([todo])) # The item should be returned with a recurrence_id of today assert len(view) == 1 assert view[0].summary == todo.summary assert view[0].dtstart == datetime.date(2024, 1, 10) assert view[0].due == datetime.date(2024, 1, 11) assert view[0].recurrence_id == "20240110" assert view[0].status == status with freezegun.freeze_time("2024-01-11T08:05:00-05:00"): view = list(todo_list_view([todo])) assert len(view) == 1 assert view[0].summary == todo.summary assert view[0].dtstart == datetime.date(2024, 1, 11) assert view[0].due == datetime.date(2024, 1, 12) assert view[0].recurrence_id == "20240111" assert view[0].status == status allenporter-ical-fe8800b/tests/test_recurrence.py000066400000000000000000000177331510550726100223120ustar00rootroot00000000000000"""Tests for timeline related calendar eents.""" from __future__ import annotations import datetime import zoneinfo import pytest from ical.exceptions import CalendarParseError from ical.calendar import Calendar from ical.component import ComponentModel from ical.exceptions import RecurrenceError from ical.event import Event from ical.parsing.property import ParsedProperty, ParsedPropertyParameter from ical.parsing.component import parse_content from ical.timeline import Timeline from ical.todo import Todo from ical.types.recur import Frequency, Recur, RecurrenceId, Weekday, WeekdayValue from ical.recurrence import Recurrences def test_from_contentlines() -> None: """Test parsing a recurrence rule from a string.""" recurrences = Recurrences.from_basic_contentlines( [ "DTSTART;TZID=America/New_York:20220802T060000", "RRULE:FREQ=DAILY;COUNT=3", ] ) assert recurrences.rrule == [ Recur( freq=Frequency.DAILY, count=3, ) ] assert recurrences.dtstart == datetime.datetime( 2022, 8, 2, 6, 0, 0, tzinfo=zoneinfo.ZoneInfo("America/New_York") ) def test_from_contentlines_rdate() -> None: """Test parsing a recurrence rule with RDATE from a string.""" lines = [ "RRULE:FREQ=DAILY;COUNT=3", "RDATE;VALUE=DATE:20220803,20220804", "IGNORED:20250806", ] # parse using full ical parser content = [ "BEGIN:RECURRENCE", *lines, "END:RECURRENCE", ] component = parse_content("\n".join(content)) assert component orig_recurrences = Recurrences.model_validate(component[0].as_dict()) recurrences = Recurrences.from_basic_contentlines(lines) assert recurrences.rrule == [ Recur( freq=Frequency.DAILY, count=3, ) ] assert recurrences.rdate == [ datetime.date(2022, 8, 3), datetime.date(2022, 8, 4), ] @pytest.mark.parametrize("property", ["RDATE", "EXDATE"]) @pytest.mark.parametrize( ("date_value", "expected"), [ ("{property}:20220803T060000", [datetime.datetime(2022, 8, 3, 6, 0, 0)]), ( "{property}:20220803T060000,20220804T060000", [ datetime.datetime(2022, 8, 3, 6, 0, 0), datetime.datetime(2022, 8, 4, 6, 0, 0), ], ), ("{property}:20220803", [datetime.date(2022, 8, 3)]), ( "{property}:20220803,20220804", [datetime.date(2022, 8, 3), datetime.date(2022, 8, 4)], ), ( "{property};VALUE=DATE:20220803,20220804", [datetime.date(2022, 8, 3), datetime.date(2022, 8, 4)], ), ( "{property};VALUE=DATE-TIME:20220803T060000,20220804T060000", [ datetime.datetime(2022, 8, 3, 6, 0, 0), datetime.datetime(2022, 8, 4, 6, 0, 0), ], ), ( "{property}:20220803T060000Z,20220804T060000Z", [ datetime.datetime(2022, 8, 3, 6, 0, 0, tzinfo=datetime.UTC), datetime.datetime(2022, 8, 4, 6, 0, 0, tzinfo=datetime.UTC), ], ), ( "{property};TZID=America/New_York:19980119T020000", [ datetime.datetime( 1998, 1, 19, 2, 0, 0, tzinfo=zoneinfo.ZoneInfo("America/New_York") ) ], ), ], ) def test_from_contentlines_date_values( property: str, date_value: str, expected: list[datetime.datetime | datetime.date] ) -> None: """Test parsing a recurrence rule with RDATE from a string.""" lines = [ "RRULE:FREQ=DAILY;COUNT=3", date_value.format(property=property), ] # Parse using full ical parser with a fake component content = [ "BEGIN:RECURRENCE", *lines, "END:RECURRENCE", ] # assert content == 'a' component = parse_content("\n".join(content)) assert component orig_recurrences = Recurrences.model_validate(component[0].as_dict()) # Parse using optimized parser recurrences = Recurrences.from_basic_contentlines(lines) # Compare both approaches assert orig_recurrences == recurrences # Additionally assert expected values from test parameters assert recurrences.rrule == [ Recur( freq=Frequency.DAILY, count=3, ) ] attr = property.lower() assert getattr(recurrences, attr) == expected @pytest.mark.parametrize( "contentlines", [ ["RRULE;COUNT=3"], ["RRULE:COUNT=3;FREQ=invalid"], ["EXDATE"], ["RDATE"], ["RRULE;COUNT=3", "EXDATE"], ["EXDATE", "RDATE"], ["EXDATE:20220803T060000", "RDATE:"], ], ) def test_from_invalid_contentlines(contentlines: list[str]) -> None: """Test parsing content lines that are not valid.""" with pytest.raises(CalendarParseError): Recurrences.from_basic_contentlines(contentlines) def test_as_rrule() -> None: """Test parsing a recurrence rule from a string.""" recurrences = Recurrences.from_basic_contentlines( [ "DTSTART:20220802T060000Z", "RRULE:FREQ=DAILY;COUNT=3", "EXDATE:20220803T060000Z", ] ) assert list(recurrences.as_rrule()) == [ datetime.datetime(2022, 8, 2, 6, 0, 0, tzinfo=datetime.UTC), datetime.datetime(2022, 8, 4, 6, 0, 0, tzinfo=datetime.UTC), ] def test_as_rrule_with_rdate() -> None: """Test parsing a recurrence rule from a string.""" recurrences = Recurrences.from_basic_contentlines( [ "DTSTART:20220801", "RDATE:20220803", "RDATE:20220804", "RDATE:20220805", ] ) assert list(recurrences.as_rrule()) == [ datetime.date(2022, 8, 3), datetime.date(2022, 8, 4), datetime.date(2022, 8, 5), ] def test_as_rrule_with_date() -> None: """Test parsing a recurrence rule from a string.""" recurrences = Recurrences.from_basic_contentlines( [ "RRULE:FREQ=DAILY;COUNT=3", "EXDATE:20220803T060000Z", ] ) assert list( recurrences.as_rrule( datetime.datetime(2022, 8, 2, 6, 0, 0, tzinfo=datetime.UTC) ) ) == [ datetime.datetime(2022, 8, 2, 6, 0, 0, tzinfo=datetime.UTC), datetime.datetime(2022, 8, 4, 6, 0, 0, tzinfo=datetime.UTC), ] def test_as_rrule_without_date() -> None: """Test parsing a recurrence rule from a string.""" recurrences = Recurrences.from_basic_contentlines( [ "RRULE:FREQ=DAILY;COUNT=3", "EXDATE:20220803T060000Z", ] ) with pytest.raises(ValueError, match="dtstart is required"): list(recurrences.as_rrule()) def test_rrule_failure() -> None: """Test parsing a recurrence rule from a string.""" recurrences = Recurrences.from_basic_contentlines( [ "DTSTART:20220802T060000Z", "RRULE:FREQ=DAILY;COUNT=3", "EXDATE:20220803T060000", ] ) with pytest.raises(RecurrenceError, match="can't compare offset-naive"): list(recurrences.as_rrule()) def test_ics() -> None: """Test parsing a recurrence rule from a string.""" recurrences = Recurrences.from_basic_contentlines( [ "DTSTART:20220802T060000Z", "RRULE:FREQ=DAILY;COUNT=3", "EXDATE:20220803T060000Z", ] ) assert recurrences.ics() == [ "DTSTART:20220802T060000Z", "RRULE:FREQ=DAILY;COUNT=3", "EXDATE:20220803T060000Z", ] def test_mismatch_date_and_datetime_types() -> None: """Test parsing a recurrence rule from a string.""" recurrences = Recurrences.from_basic_contentlines( [ "DTSTART:20220801T060000Z", "RDATE:20220803", "RDATE:20220804T060000Z", "RDATE:20220805", ] ) with pytest.raises(RecurrenceError): list(recurrences.as_rrule()) allenporter-ical-fe8800b/tests/test_store.py000066400000000000000000001625471510550726100213150ustar00rootroot00000000000000"""Tests for the event store.""" # pylint: disable=too-many-lines from __future__ import annotations import datetime import zoneinfo from collections.abc import Callable, Generator from typing import Any from unittest.mock import patch import itertools import pathlib import pytest from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory from syrupy import SnapshotAssertion from ical.calendar import Calendar from ical.calendar_stream import IcsCalendarStream from ical.event import Event from ical.todo import Todo from ical.store import EventStore, TodoStore, StoreError from ical.types.recur import Range, Recur from ical.types import RelationshipType, RelatedTo TZ = zoneinfo.ZoneInfo("America/Los_Angeles") @pytest.fixture(name="calendar") def mock_calendar() -> Calendar: """Fixture to create a calendar.""" return Calendar() @pytest.fixture(name="store") def mock_store(calendar: Calendar) -> EventStore: """Fixture to create an event store.""" return EventStore(calendar) @pytest.fixture(name="todo_store") def mock_todo_store(calendar: Calendar) -> TodoStore: """Fixture to create an event store.""" return TodoStore(calendar, tzinfo=TZ) @pytest.fixture(name="_uid", autouse=True) def mock_uid() -> Generator[None, None, None]: """Patch out uuid creation with a fixed value.""" counter = 0 def func() -> str: nonlocal counter counter += 1 return f"mock-uid-{counter}" with ( patch("ical.event.uid_factory", new=func), patch("ical.todo.uid_factory", new=func), ): yield def compact_dict(data: dict[str, Any], keys: set[str] | None = None) -> dict[str, Any]: """Convert pydantic dict values to text.""" for key, value in list(data.items()): if value is None or isinstance(value, list) and not value or value == "": del data[key] elif keys and key not in keys: del data[key] elif isinstance(value, datetime.datetime): data[key] = value.isoformat() elif isinstance(value, datetime.date): data[key] = value.isoformat() return data @pytest.fixture(name="fetch_events") def mock_fetch_events( calendar: Calendar, ) -> Callable[..., list[dict[str, Any]]]: """Fixture to return events on the calendar.""" def _func(keys: set[str] | None = None) -> list[dict[str, Any]]: return [compact_dict(event.model_dump(), keys) for event in calendar.timeline] return _func @pytest.fixture(name="fetch_todos") def mock_fetch_todos( todo_store: TodoStore, ) -> Callable[..., list[dict[str, Any]]]: """Fixture to return todos on the calendar.""" def _func(keys: set[str] | None = None) -> list[dict[str, Any]]: return [ compact_dict(todo.model_dump(), keys) for todo in todo_store.todo_list() ] return _func @pytest.fixture(name="frozen_time", autouse=True) def mock_frozen_time() -> Generator[FrozenDateTimeFactory, None, None]: """Fixture to freeze time to a specific point.""" with freeze_time("2022-09-03T09:38:05") as freeze: with patch("ical.event.dtstamp_factory", new=freeze): yield freeze def test_empty_store(fetch_events: Callable[..., list[dict[str, Any]]]) -> None: """Test iteration over an empty calendar.""" assert fetch_events() == [] def test_add_and_delete_event( store: EventStore, fetch_events: Callable[..., list[dict[str, Any]]], snapshot: SnapshotAssertion, ) -> None: """Test adding an event to the store and retrieval.""" store.add( Event( summary="Monday meeting", start="2022-08-29T09:00:00", end="2022-08-29T09:30:00", ) ) assert fetch_events() == snapshot store.delete("mock-uid-1") assert fetch_events() == [] def test_edit_event( store: EventStore, fetch_events: Callable[..., list[dict[str, Any]]], frozen_time: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test editing an event.""" store.add( Event( summary="Monday meeting", start="2022-08-29T09:00:00", end="2022-08-29T09:30:00", ) ) assert fetch_events() == snapshot frozen_time.tick(delta=datetime.timedelta(seconds=10)) # Set event start time 5 minutes later store.edit( "mock-uid-1", Event(start="2022-08-29T09:05:00", summary="Monday meeting (Delayed)"), ) assert fetch_events() == snapshot def test_edit_event_invalid_uid(store: EventStore) -> None: """Edit an event that does not exist.""" with pytest.raises(StoreError, match="No existing"): store.edit("mock-uid-1", Event(start="2022-08-29T09:05:00", summary="Delayed")) @pytest.mark.parametrize( ("start", "end", "recur"), [ ( datetime.datetime(2022, 8, 29, 9, 0), datetime.datetime(2022, 8, 29, 9, 30), Recur.from_rrule("FREQ=WEEKLY;UNTIL=20220926T090000"), ), ( datetime.datetime(2022, 8, 29, 9, 0), datetime.datetime(2022, 8, 29, 9, 30), Recur.from_rrule("FREQ=WEEKLY;COUNT=5"), ), ( datetime.datetime( 2022, 8, 29, 9, 0, tzinfo=zoneinfo.ZoneInfo("America/Los_Angeles") ), datetime.datetime( 2022, 8, 29, 9, 30, tzinfo=zoneinfo.ZoneInfo("America/Los_Angeles") ), Recur.from_rrule("FREQ=WEEKLY;COUNT=5"), ), ], ) def test_recurring_event( store: EventStore, fetch_events: Callable[..., list[dict[str, Any]]], start: datetime.datetime, end: datetime.datetime, recur: Recur, snapshot: SnapshotAssertion, ) -> None: """Test adding a recurring event and deleting the entire series.""" store.add( Event( summary="Monday meeting", start=start, end=end, rrule=recur, ) ) assert fetch_events({"uid", "recurrence_id", "dtstart", "summary"}) == snapshot store.delete("mock-uid-1") assert fetch_events(None) == [] @pytest.mark.parametrize( "recur", [ Recur.from_rrule("FREQ=WEEKLY;UNTIL=20220926T090000"), Recur.from_rrule("FREQ=WEEKLY;COUNT=5"), ], ) def test_deletel_partial_recurring_event( store: EventStore, fetch_events: Callable[..., list[dict[str, Any]]], recur: Recur, snapshot: SnapshotAssertion, ) -> None: """Test adding a recurring event and deleting part of the series.""" store.add( Event( summary="Monday meeting", start="2022-08-29T09:00:00", end="2022-08-29T09:30:00", rrule=recur, ) ) store.delete(uid="mock-uid-1", recurrence_id="20220905T090000") store.delete(uid="mock-uid-1", recurrence_id="20220919T090000") assert fetch_events({"uid", "recurrence_id", "dtstart", "summary"}) == snapshot @pytest.mark.parametrize( "recur", [ Recur.from_rrule("FREQ=WEEKLY;UNTIL=20220926T090000"), Recur.from_rrule("FREQ=WEEKLY;COUNT=5"), ], ) def test_delete_this_and_future_event( store: EventStore, fetch_events: Callable[..., list[dict[str, Any]]], recur: Recur, snapshot: SnapshotAssertion, ) -> None: """Test adding a recurring event and deleting events after one event.""" store.add( Event( summary="Monday meeting", start="2022-08-29T09:00:00", end="2022-08-29T09:30:00", rrule=recur, ) ) store.delete( uid="mock-uid-1", recurrence_id="20220919T090000", recurrence_range=Range.THIS_AND_FUTURE, ) assert fetch_events({"uid", "recurrence_id", "dtstart", "summary"}) == snapshot @pytest.mark.parametrize( "recur", [ Recur.from_rrule("FREQ=WEEKLY;UNTIL=20220926"), Recur.from_rrule("FREQ=WEEKLY;COUNT=5"), ], ) def test_delete_this_and_future_all_day_event( store: EventStore, fetch_events: Callable[..., list[dict[str, Any]]], recur: Recur, snapshot: SnapshotAssertion, ) -> None: """Test adding a recurring event and deleting events after one event.""" store.add( Event( summary="Mondays", start="2022-08-29", end="2022-08-30", rrule=recur, ) ) store.delete( uid="mock-uid-1", recurrence_id="20220919", recurrence_range=Range.THIS_AND_FUTURE, ) assert fetch_events({"uid", "recurrence_id", "dtstart", "summary"}) == snapshot @pytest.mark.parametrize( "recur", [ Recur.from_rrule("FREQ=WEEKLY;UNTIL=20220926T090000"), Recur.from_rrule("FREQ=WEEKLY;COUNT=5"), ], ) def test_delete_this_and_future_event_with_first_instance( calendar: Calendar, store: EventStore, fetch_events: Callable[..., list[dict[str, Any]]], recur: Recur, ) -> None: """Test deleting this and future for the first instance.""" store.add( Event( summary="Monday meeting", start="2022-08-29T09:00:00", end="2022-08-29T09:30:00", rrule=recur, ) ) assert len(calendar.events) == 1 store.delete( uid="mock-uid-1", recurrence_id="20220829T090000", recurrence_range=Range.THIS_AND_FUTURE, ) assert fetch_events({"uid", "recurrence_id", "dtstart", "summary"}) == [] assert len(calendar.events) == 0 @pytest.mark.parametrize( "recur", [ Recur.from_rrule("FREQ=WEEKLY;UNTIL=20220926"), Recur.from_rrule("FREQ=WEEKLY;COUNT=5"), ], ) def test_delete_this_and_future_all_day_event_with_first_instance( calendar: Calendar, store: EventStore, fetch_events: Callable[..., list[dict[str, Any]]], recur: Recur, ) -> None: """Test deleting this and future for the first instance.""" store.add( Event( summary="Mondays", start="2022-08-29", end="2022-08-29", rrule=recur, ) ) assert len(calendar.events) == 1 store.delete( uid="mock-uid-1", recurrence_id="20220829", recurrence_range=Range.THIS_AND_FUTURE, ) assert fetch_events({"uid", "recurrence_id", "dtstart", "summary"}) == [] assert len(calendar.events) == 0 @pytest.mark.parametrize( "recur", [ Recur.from_rrule("FREQ=WEEKLY;UNTIL=20220913T090000"), Recur.from_rrule("FREQ=WEEKLY;COUNT=3"), ], ) def test_edit_recurring_event( store: EventStore, fetch_events: Callable[..., list[dict[str, Any]]], frozen_time: FrozenDateTimeFactory, recur: Recur, snapshot: SnapshotAssertion, ) -> None: """Test editing all instances of a recurring event.""" store.add( Event( summary="Monday meeting", start="2022-08-29T09:00:00", end="2022-08-29T09:30:00", rrule=recur, ) ) frozen_time.tick(delta=datetime.timedelta(seconds=10)) store.edit( "mock-uid-1", Event(start="2022-08-30T09:00:00", summary="Tuesday meeting"), ) assert fetch_events({"uid", "recurrence_id", "dtstart", "summary"}) == snapshot @pytest.mark.parametrize( "recur", [ Recur.from_rrule("FREQ=WEEKLY;UNTIL=20220912T090000"), Recur.from_rrule("FREQ=WEEKLY;COUNT=3"), ], ) def test_edit_recurring_all_day_event_instance( store: EventStore, fetch_events: Callable[..., list[dict[str, Any]]], frozen_time: FrozenDateTimeFactory, recur: Recur, snapshot: SnapshotAssertion, ) -> None: """Test editing a single instance of a recurring all day event.""" store.add( Event( summary="Monday event", start="2022-08-29", end="2022-08-30", rrule=recur, ) ) frozen_time.tick(delta=datetime.timedelta(seconds=10)) store.edit( "mock-uid-1", Event(start="2022-09-06", summary="Tuesday event"), recurrence_id="20220905", ) assert ( fetch_events({"uid", "recurrence_id", "sequence", "dtstart", "summary"}) == snapshot ) @pytest.mark.parametrize( "recur", [ Recur.from_rrule("FREQ=WEEKLY;UNTIL=20220912T090000"), Recur.from_rrule("FREQ=WEEKLY;COUNT=3"), ], ) def test_edit_recurring_event_instance( store: EventStore, fetch_events: Callable[..., list[dict[str, Any]]], frozen_time: FrozenDateTimeFactory, recur: Recur, snapshot: SnapshotAssertion, ) -> None: """Test editing a single instance of a recurring event.""" store.add( Event( summary="Monday meeting", start="2022-08-29T09:00:00", end="2022-08-29T09:30:00", rrule=recur, ) ) frozen_time.tick(delta=datetime.timedelta(seconds=10)) store.edit( "mock-uid-1", Event(start="2022-09-06T09:00:00", summary="Tuesday meeting"), recurrence_id="20220905T090000", ) assert fetch_events({"uid", "recurrence_id", "dtstart", "summary"}) == snapshot def test_edit_recurring_with_same_rrule( store: EventStore, fetch_events: Callable[..., list[dict[str, Any]]], frozen_time: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test that changing the rrule to the same value is a no-op.""" store.add( Event( summary="Monday meeting", start="2022-08-29T09:00:00", end="2022-08-29T09:30:00", rrule=Recur.from_rrule("FREQ=WEEKLY;COUNT=2"), ) ) frozen_time.tick(delta=datetime.timedelta(seconds=10)) store.edit( "mock-uid-1", Event( start="2022-08-30T09:00:00", summary="Tuesday meeting", rrule=Recur.from_rrule("FREQ=WEEKLY;COUNT=2"), ), ) assert fetch_events({"uid", "recurrence_id", "dtstart", "summary"}) == snapshot def test_cant_change_recurrence_for_event_instance( store: EventStore, frozen_time: FrozenDateTimeFactory, ) -> None: """Test editing all instances of a recurring event.""" store.add( Event( summary="Monday meeting", start="2022-08-29T09:00:00", end="2022-08-29T09:30:00", rrule=Recur.from_rrule("FREQ=WEEKLY;COUNT=3"), ) ) frozen_time.tick(delta=datetime.timedelta(seconds=10)) with pytest.raises(StoreError, match="single instance with rrule"): store.edit( "mock-uid-1", Event( start="2022-09-06T09:00:00", summary="Tuesday meeting", rrule=Recur.from_rrule("FREQ=DAILY;COUNT=3"), ), recurrence_id="20220905T090000", ) def test_convert_single_instance_to_recurring( store: EventStore, frozen_time: FrozenDateTimeFactory, fetch_events: Callable[..., list[dict[str, Any]]], snapshot: SnapshotAssertion, ) -> None: """Test editing all instances of a recurring event.""" store.add( Event( summary="Daily meeting", start="2022-08-29T09:00:00", end="2022-08-29T09:30:00", ) ) assert fetch_events({"uid", "recurrence_id", "dtstart", "summary"}) == snapshot frozen_time.tick(delta=datetime.timedelta(seconds=10)) store.edit( "mock-uid-1", Event( start="2022-08-29T09:00:00", end="2022-08-29T09:30:00", summary="Daily meeting", rrule=Recur.from_rrule("FREQ=DAILY;COUNT=3"), ), ) assert fetch_events({"uid", "recurrence_id", "dtstart", "summary"}) == snapshot @pytest.mark.parametrize( "recur", [ Recur.from_rrule("FREQ=WEEKLY;UNTIL=20220912T090000"), Recur.from_rrule("FREQ=WEEKLY;COUNT=3"), ], ) def test_edit_recurring_event_this_and_future( store: EventStore, fetch_events: Callable[..., list[dict[str, Any]]], frozen_time: FrozenDateTimeFactory, recur: Recur, snapshot: SnapshotAssertion, ) -> None: """Test editing future instance of a recurring event.""" store.add( Event( summary="Monday meeting", start="2022-08-29T09:00:00", end="2022-08-29T09:30:00", rrule=recur, ) ) frozen_time.tick(delta=datetime.timedelta(seconds=10)) store.edit( "mock-uid-1", Event(summary="Team meeting"), recurrence_id="20220905T090000", recurrence_range=Range.THIS_AND_FUTURE, ) assert fetch_events({"uid", "recurrence_id", "dtstart", "summary"}) == snapshot @pytest.mark.parametrize( "recur", [ Recur.from_rrule("FREQ=WEEKLY;UNTIL=20220912"), Recur.from_rrule("FREQ=WEEKLY;COUNT=3"), ], ) def test_edit_recurring_all_day_event_this_and_future( store: EventStore, fetch_events: Callable[..., list[dict[str, Any]]], frozen_time: FrozenDateTimeFactory, recur: Recur, snapshot: SnapshotAssertion, ) -> None: """Test editing future instance of a recurring event.""" store.add( Event( summary="Monday", start="2022-08-29", end="2022-08-30", rrule=recur, ) ) frozen_time.tick(delta=datetime.timedelta(seconds=10)) store.edit( "mock-uid-1", Event(summary="Mondays [edit]"), recurrence_id="20220905", recurrence_range=Range.THIS_AND_FUTURE, ) assert fetch_events({"uid", "recurrence_id", "dtstart", "summary"}) == snapshot def test_delete_all_day_event( store: EventStore, fetch_events: Callable[..., list[dict[str, Any]]], snapshot: SnapshotAssertion, ) -> None: """Test deleting a single all day event.""" store.add( Event( summary="Monday meeting", start="2022-08-29", end="2022-08-29", ) ) assert fetch_events() == snapshot store.delete("mock-uid-1") assert fetch_events() == [] def test_delete_all_day_recurring( store: EventStore, fetch_events: Callable[..., list[dict[str, Any]]], snapshot: SnapshotAssertion, ) -> None: """Test deleting all instances of a recurring all day event.""" store.add( Event( summary="Monday meeting", start="2022-08-29", end="2022-08-29", rrule=Recur.from_rrule("FREQ=WEEKLY;COUNT=3"), ) ) assert fetch_events({"uid", "recurrence_id", "dtstart", "summary"}) == snapshot store.delete("mock-uid-1", recurrence_id="20220905") assert fetch_events({"uid", "recurrence_id", "dtstart", "summary"}) == snapshot def test_edit_recurrence_rule_this_and_future( store: EventStore, fetch_events: Callable[..., list[dict[str, Any]]], frozen_time: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test editing future instances of a recurring event.""" store.add( Event( summary="Monday meeting", start="2022-08-29T09:00:00", end="2022-08-29T09:30:00", rrule=Recur.from_rrule("FREQ=WEEKLY;COUNT=3"), ) ) frozen_time.tick(delta=datetime.timedelta(seconds=10)) store.edit( "mock-uid-1", Event( summary="Team meeting", rrule=Recur.from_rrule("FREQ=WEEKLY;COUNT=3;INTERVAL=2"), ), recurrence_id="20220905T090000", recurrence_range=Range.THIS_AND_FUTURE, ) assert fetch_events({"uid", "recurrence_id", "dtstart", "summary"}) == snapshot def test_edit_recurrence_rule_this_and_future_all_day_first_instance( store: EventStore, fetch_events: Callable[..., list[dict[str, Any]]], frozen_time: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test editing future instances starting at the first instance.""" store.add( Event( summary="Monday", start="2022-08-29", end="2022-08-30", rrule=Recur.from_rrule("FREQ=WEEKLY;COUNT=3"), ) ) frozen_time.tick(delta=datetime.timedelta(seconds=10)) store.edit( "mock-uid-1", Event( summary="Mondays [edit]", rrule=Recur.from_rrule("FREQ=WEEKLY;COUNT=3;INTERVAL=2"), ), recurrence_id="20220829", recurrence_range=Range.THIS_AND_FUTURE, ) assert fetch_events({"uid", "recurrence_id", "dtstart", "summary"}) == snapshot def test_edit_recurrence_rule_this_and_future_first_instance( store: EventStore, fetch_events: Callable[..., list[dict[str, Any]]], frozen_time: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test editing future instances starting at the first instance.""" store.add( Event( summary="Monday meeting", start="2022-08-29T09:00:00", end="2022-08-29T09:30:00", rrule=Recur.from_rrule("FREQ=WEEKLY;COUNT=3"), ) ) frozen_time.tick(delta=datetime.timedelta(seconds=10)) store.edit( "mock-uid-1", Event( summary="Team meeting", rrule=Recur.from_rrule("FREQ=WEEKLY;COUNT=3;INTERVAL=2"), ), recurrence_id="20220829T090000", recurrence_range=Range.THIS_AND_FUTURE, ) assert fetch_events({"uid", "recurrence_id", "dtstart", "summary"}) == snapshot def test_invalid_uid( store: EventStore, ) -> None: """Test iteration over an empty calendar.""" with pytest.raises(StoreError, match=r"No existing item with uid"): store.edit("invalid", Event(summary="example summary")) with pytest.raises(StoreError, match=r"No existing item with uid"): store.delete("invalid") def test_invalid_recurrence_id( store: EventStore, ) -> None: """Test adding an event to the store and retrieval.""" store.add( Event( uid="mock-uid-1", summary="Monday meeting", start="2022-08-29T09:00:00", end="2022-08-29T09:30:00", ) ) with pytest.raises(StoreError, match=r"No existing item"): store.delete("mock-uid-1", recurrence_id="20220828T090000") with pytest.raises(StoreError, match=r"No existing item with"): store.edit("mock-uid-1", Event(summary="tuesday"), recurrence_id="20210828") def test_no_timezone_for_floating( calendar: Calendar, store: EventStore, ) -> None: """Test adding an event to the store and retrieval.""" store.add( Event( summary="Monday meeting", start=datetime.datetime(2022, 8, 29, 9, 0, 0), end=datetime.datetime(2022, 8, 29, 9, 30, 0), ) ) assert len(calendar.events) == 1 assert not calendar.timezones def test_no_timezone_for_utc( calendar: Calendar, store: EventStore, ) -> None: """Test adding an event to the store and retrieval.""" store.add( Event( summary="Monday meeting", start=datetime.datetime(2022, 8, 29, 9, 0, 0, tzinfo=datetime.timezone.utc), end=datetime.datetime(2022, 8, 29, 9, 30, 0, tzinfo=datetime.timezone.utc), ) ) assert len(calendar.events) == 1 assert not calendar.timezones def test_timezone_for_datetime( calendar: Calendar, store: EventStore, ) -> None: """Test adding an event to the store and retrieval.""" store.add( Event( summary="Monday meeting", start=datetime.datetime( 2022, 8, 29, 9, 0, 0, tzinfo=zoneinfo.ZoneInfo("America/Los_Angeles") ), end=datetime.datetime( 2022, 8, 29, 9, 30, 0, tzinfo=zoneinfo.ZoneInfo("America/Los_Angeles") ), ) ) assert len(calendar.events) == 1 assert len(calendar.timezones) == 1 assert calendar.timezones[0].tz_id == "America/Los_Angeles" store.add( Event( summary="Tuesday meeting", start=datetime.datetime( 2022, 8, 30, 9, 0, 0, tzinfo=zoneinfo.ZoneInfo("America/Los_Angeles") ), end=datetime.datetime( 2022, 8, 30, 9, 30, 0, tzinfo=zoneinfo.ZoneInfo("America/Los_Angeles") ), ) ) # Timezone already exists assert len(calendar.timezones) == 1 store.add( Event( summary="Wednesday meeting", start=datetime.datetime( 2022, 8, 31, 12, 0, 0, tzinfo=zoneinfo.ZoneInfo("America/New_York") ), end=datetime.datetime( 2022, 8, 31, 12, 30, 0, tzinfo=zoneinfo.ZoneInfo("America/New_York") ), ) ) assert len(calendar.timezones) == 2 assert calendar.timezones[0].tz_id == "America/Los_Angeles" assert calendar.timezones[1].tz_id == "America/New_York" def test_timezone_for_dtend( calendar: Calendar, store: EventStore, ) -> None: """Test adding an event to the store and retrieval.""" store.add( Event( uid="mock-uid-1", summary="Monday meeting", start=datetime.datetime( 2022, 8, 29, 9, 0, 0, tzinfo=zoneinfo.ZoneInfo("America/Los_Angeles") ), end=datetime.datetime( 2022, 8, 29, 8, 30, 0, tzinfo=zoneinfo.ZoneInfo("America/New_York") ), ) ) assert len(calendar.events) == 1 assert len(calendar.timezones) == 2 assert calendar.timezones[0].tz_id == "America/Los_Angeles" assert calendar.timezones[1].tz_id == "America/New_York" store.edit( "mock-uid-1", Event( end=datetime.datetime( 2022, 8, 29, 8, 30, 0, tzinfo=zoneinfo.ZoneInfo("America/Denver") ), ), ) assert len(calendar.events) == 1 assert len(calendar.timezones) == 3 assert calendar.timezones[0].tz_id == "America/Los_Angeles" assert calendar.timezones[1].tz_id == "America/New_York" assert calendar.timezones[2].tz_id == "America/Denver" def test_timezone_offset_not_supported( calendar: Calendar, store: EventStore, ) -> None: """Test adding a datetime for a timestamp that does not have a valid timezone.""" offset = datetime.timedelta(hours=-8) tzinfo = datetime.timezone(offset=offset) event = Event( summary="Monday meeting", start=datetime.datetime(2022, 8, 29, 9, 0, 0, tzinfo=tzinfo), end=datetime.datetime(2022, 8, 29, 9, 30, 0, tzinfo=tzinfo), ) with pytest.raises( StoreError, match=r"No timezone information available for event: UTC-08:00" ): store.add(event) assert not calendar.events assert not calendar.timezones def test_delete_event_parent_cascade_to_children( store: EventStore, fetch_events: Callable[..., list[dict[str, Any]]], snapshot: SnapshotAssertion, ) -> None: """Test deleting a parent event object deletes the children.""" event1 = store.add( Event( summary="Submit IRS documents", start="2022-08-29T09:00:00", duration=datetime.timedelta(minutes=30), ) ) assert event1.uid == "mock-uid-1" event2 = store.add( Event( summary="Lookup website", start="2022-08-29T10:00:00", duration=datetime.timedelta(minutes=30), related_to=[RelatedTo(uid="mock-uid-1", reltype=RelationshipType.PARENT)], ) ) assert event2.uid == "mock-uid-2" event3 = store.add( Event( summary="Download forms", start="2022-08-29T11:00:00", duration=datetime.timedelta(minutes=30), related_to=[RelatedTo(uid="mock-uid-1", reltype=RelationshipType.PARENT)], ) ) assert event3.uid == "mock-uid-3" store.add( Event( summary="Milk", start="2022-08-29T12:00:00", duration=datetime.timedelta(minutes=30), ) ) assert [item["uid"] for item in fetch_events()] == snapshot # Delete parent and cascade to children store.delete("mock-uid-1") assert [item["uid"] for item in fetch_events()] == snapshot @pytest.mark.parametrize( "reltype", [ (RelationshipType.SIBBLING), (RelationshipType.CHILD), ], ) def test_unsupported_event_reltype( store: EventStore, reltype: RelationshipType, ) -> None: """Test that only PARENT relationships can be managed by the store.""" with pytest.raises(StoreError, match=r"Unsupported relationship type"): store.add( Event( summary="Lookup website", related_to=[RelatedTo(uid="mock-uid-1", reltype=reltype)], ) ) event1 = store.add( Event( summary="Parent", ) ) event2 = store.add( Event( summary="Future child", ) ) event2.related_to = [RelatedTo(uid=event1.uid, reltype=reltype)] with pytest.raises(StoreError, match=r"Unsupported relationship type"): store.edit(event2.uid, event2) def test_add_and_delete_todo( todo_store: TodoStore, fetch_todos: Callable[..., list[dict[str, Any]]], snapshot: SnapshotAssertion, ) -> None: """Test adding a todo to the store and retrieval.""" todo_store.add( Todo( summary="Monday meeting", due="2022-08-29T09:00:00", ) ) assert fetch_todos() == snapshot todo_store.delete("mock-uid-1") assert fetch_todos() == [] @pytest.mark.parametrize( "status", [ {"status": "NEEDS-ACTION"}, {"status": "COMPLETED"}, {"status": "COMPLETED", "completed": "2020-01-01T00:00:00+00:00"}, ], ids=["needs_action", "completed", "completed_with_timestamp"], ) def test_add_todo_with_status( todo_store: TodoStore, fetch_todos: Callable[..., list[dict[str, Any]]], snapshot: SnapshotAssertion, status: dict[str, Any], ) -> None: """Test adding a todo to the store with a status.""" todo_store.add( Todo( summary="Do chores", due="2022-08-29T09:00:00", **status, ) ) assert fetch_todos() == snapshot def test_edit_todo( todo_store: TodoStore, fetch_todos: Callable[..., list[dict[str, Any]]], frozen_time: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test editing an todo preserves order.""" todo_store.add( Todo( summary="Monday morning items", due="2022-08-29T09:00:00", ) ) todo_store.add( Todo( summary="Tuesday morning items", due="2022-08-30T09:00:00", ) ) assert fetch_todos() == snapshot frozen_time.tick(delta=datetime.timedelta(seconds=10)) # Set event start time 5 minutes later todo_store.edit( "mock-uid-1", Todo(due="2022-08-29T09:05:00", summary="Monday morning items (Delayed)"), ) assert fetch_todos() == snapshot def test_edit_todo_status( todo_store: TodoStore, fetch_todos: Callable[..., list[dict[str, Any]]], frozen_time: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test changing todo status updates completed timestamp.""" todo_store.add( Todo( summary="Monday morning items", due="2022-08-29T09:00:00", ) ) assert fetch_todos() == snapshot(name="initial") frozen_time.tick(delta=datetime.timedelta(seconds=10)) todo_store.edit( "mock-uid-1", Todo(status="COMPLETED"), ) assert fetch_todos() == snapshot(name="completed") frozen_time.tick(delta=datetime.timedelta(seconds=20)) # Test that modifying other fields does not change the completion time todo_store.edit( "mock-uid-1", Todo(due="2022-08-29T09:05:00", summary="Monday morning items (Delayed)"), ) assert fetch_todos() == snapshot(name="edit_summary") frozen_time.tick(delta=datetime.timedelta(seconds=30)) # Test that setting status again does not change the completion time todo_store.edit( "mock-uid-1", Todo(status="COMPLETED"), ) assert fetch_todos() == snapshot(name="completed_again") frozen_time.tick(delta=datetime.timedelta(seconds=40)) todo_store.edit( "mock-uid-1", Todo(status="NEEDS-ACTION"), ) assert fetch_todos() == snapshot(name="needs_action") def test_todo_store_invalid_uid(todo_store: TodoStore) -> None: """Edit a todo that does not exist.""" with pytest.raises(StoreError, match="No existing"): todo_store.edit( "mock-uid-1", Todo(due="2022-08-29T09:05:00", summary="Delayed") ) with pytest.raises(StoreError, match="No existing"): todo_store.delete("mock-uid-1") def test_todo_timezone_for_datetime( calendar: Calendar, todo_store: TodoStore, ) -> None: """Test adding an event to the store and retrieval.""" todo_store.add( Todo( summary="Monday meeting", dtstart=datetime.datetime( 2022, 8, 29, 8, 0, 0, tzinfo=zoneinfo.ZoneInfo("America/Los_Angeles") ), due=datetime.datetime( 2022, 8, 29, 9, 0, 0, tzinfo=zoneinfo.ZoneInfo("America/Los_Angeles") ), ) ) assert len(calendar.todos) == 1 assert len(calendar.timezones) == 1 assert calendar.timezones[0].tz_id == "America/Los_Angeles" todo_store.add( Todo( summary="Tuesday meeting", dtstart=datetime.datetime( 2022, 8, 30, 8, 0, 0, tzinfo=zoneinfo.ZoneInfo("America/Los_Angeles") ), due=datetime.datetime( 2022, 8, 30, 9, 0, 0, tzinfo=zoneinfo.ZoneInfo("America/Los_Angeles") ), ) ) # Timezone already exists assert len(calendar.timezones) == 1 todo_store.add( Todo( summary="Wednesday meeting", dtstart=datetime.datetime( 2022, 8, 31, 11, 0, 0, tzinfo=zoneinfo.ZoneInfo("America/New_York") ), due=datetime.datetime( 2022, 8, 31, 12, 0, 0, tzinfo=zoneinfo.ZoneInfo("America/New_York") ), ) ) assert len(calendar.timezones) == 2 assert calendar.timezones[0].tz_id == "America/Los_Angeles" assert calendar.timezones[1].tz_id == "America/New_York" def test_todo_timezone_offset_not_supported( calendar: Calendar, todo_store: TodoStore, ) -> None: """Test adding a datetime for a timestamp that does not have a valid timezone.""" offset = datetime.timedelta(hours=-8) tzinfo = datetime.timezone(offset=offset) event = Todo( summary="Monday meeting", dtstart=datetime.datetime(2022, 8, 29, 9, 0, 0, tzinfo=tzinfo), due=datetime.datetime(2022, 8, 30, 9, 0, 0, tzinfo=tzinfo), ) with pytest.raises(StoreError, match=r"No timezone information"): todo_store.add(event) assert not calendar.todos assert not calendar.timezones def test_delete_parent_todo_cascade_to_children( todo_store: TodoStore, fetch_todos: Callable[..., list[dict[str, Any]]], snapshot: SnapshotAssertion, ) -> None: """Test deleting a parent todo object deletes the children.""" todo1 = todo_store.add( Todo( summary="Submit IRS documents", due="2022-08-29T09:00:00", ) ) assert todo1.uid == "mock-uid-1" todo2 = todo_store.add( Todo( summary="Lookup website", related_to=[RelatedTo(uid="mock-uid-1", reltype=RelationshipType.PARENT)], ) ) assert todo2.uid == "mock-uid-2" todo3 = todo_store.add( Todo( summary="Download forms", related_to=[RelatedTo(uid="mock-uid-1", reltype=RelationshipType.PARENT)], ) ) assert todo3.uid == "mock-uid-3" todo_store.add( Todo( summary="Milk", ) ) assert [item["uid"] for item in fetch_todos()] == snapshot # Delete parent and cascade to children todo_store.delete("mock-uid-1") assert [item["uid"] for item in fetch_todos()] == snapshot @pytest.mark.parametrize( "reltype", [ (RelationshipType.SIBBLING), (RelationshipType.CHILD), ], ) def test_unsupported_todo_reltype( todo_store: TodoStore, reltype: RelationshipType, ) -> None: """Test that only PARENT relationships can be managed by the store.""" with pytest.raises(StoreError, match=r"Unsupported relationship type"): todo_store.add( Todo( summary="Lookup website", related_to=[RelatedTo(uid="mock-uid-1", reltype=reltype)], ) ) todo1 = todo_store.add( Todo( summary="Parent", ) ) todo2 = todo_store.add( Todo( summary="Future child", ) ) todo2.related_to = [RelatedTo(uid=todo1.uid, reltype=reltype)] with pytest.raises(StoreError, match=r"Unsupported relationship type"): todo_store.edit(todo2.uid, todo2) def test_recurring_todo_item_edit_series( calendar: Calendar, todo_store: TodoStore, fetch_todos: Callable[..., list[dict[str, Any]]], frozen_time: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test editing an item that affects the entire series.""" frozen_time.move_to("2024-01-09T10:00:05") # Create a recurring to-do item todo_store.add( Todo( summary="Walk dog", dtstart="2024-01-09", due="2024-01-10", status="NEEDS-ACTION", rrule=Recur.from_rrule("FREQ=DAILY;COUNT=10"), ) ) assert fetch_todos( ["uid", "recurrence_id", "due", "summary", "status"] ) == snapshot(name="initial") # Mark the entire series as completed todo_store.edit("mock-uid-1", Todo(status="COMPLETED")) assert fetch_todos( ["uid", "recurrence_id", "due", "summary", "status"] ) == snapshot(name="completed") # Advance to the next day. frozen_time.move_to("2024-01-10T10:00:00") # All instances are completed assert fetch_todos( ["uid", "recurrence_id", "due", "summary", "status"] ) == snapshot(name="next_instance") assert IcsCalendarStream.calendar_to_ics(calendar) == snapshot def test_recurring_todo_item_edit_single( calendar: Calendar, todo_store: TodoStore, fetch_todos: Callable[..., list[dict[str, Any]]], frozen_time: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test editing a single recurring item.""" frozen_time.move_to("2024-01-09T10:00:05") # Create a recurring to-do item todo_store.add( Todo( summary="Walk dog", dtstart="2024-01-09", due="2024-01-10", status="NEEDS-ACTION", rrule=Recur.from_rrule("FREQ=DAILY;COUNT=10"), ) ) # There is a single underlying instance assert len(calendar.todos) == 1 assert fetch_todos( ["uid", "recurrence_id", "due", "summary", "status"] ) == snapshot(name="initial") # Mark a single instance as completed todo_store.edit("mock-uid-1", Todo(status="COMPLETED"), recurrence_id="20240109") # There are now two underlying instances assert len(calendar.todos) == 2 # Collapsed view of a single item assert fetch_todos( ["uid", "recurrence_id", "due", "summary", "status"] ) == snapshot(name="completed") # Advance to the next day and a new incomplete instance appears frozen_time.move_to("2024-01-10T10:00:00") assert fetch_todos( ["uid", "recurrence_id", "due", "summary", "status"] ) == snapshot(name="next_instance") # Mark the new instance as completed todo_store.edit("mock-uid-1", Todo(status="COMPLETED"), recurrence_id="20240110") assert len(calendar.todos) == 3 assert IcsCalendarStream.calendar_to_ics(calendar) == snapshot(name="result_ics") # Also edit the instance summary and verify that it can be modified again todo_store.edit("mock-uid-1", Todo(summary="Walk cat"), recurrence_id="20240110") assert len(calendar.todos) == 3 assert IcsCalendarStream.calendar_to_ics(calendar) == snapshot( name="result_ics_modified" ) # Collapsed view of the same item assert fetch_todos( ["uid", "recurrence_id", "due", "summary", "status"] ) == snapshot(name="next_instance_completed") # Delete a single instance and the following days instance appears. This is # not really a common operation, but still worth exercsing the behavior. todo_store.delete("mock-uid-1", recurrence_id="20240110") # Now only two underlying objects # The prior instance is the latest on the list assert fetch_todos( ["uid", "recurrence_id", "due", "summary", "status"] ) == snapshot(name="next_instance_deleted") assert IcsCalendarStream.calendar_to_ics(calendar) == snapshot( name="next_instance_deleted_ics" ) # Delete the entire series todo_store.delete("mock-uid-1") assert not calendar.todos assert IcsCalendarStream.calendar_to_ics(calendar) == snapshot( name="deleted_series_ics" ) def test_delete_todo_series( calendar: Calendar, todo_store: TodoStore, fetch_todos: Callable[..., list[dict[str, Any]]], frozen_time: FrozenDateTimeFactory, ) -> None: """Test deleting a recurring todo item with edits applied.""" # Create a recurring to-do item todo_store.add( Todo( summary="Walk dog", dtstart="2024-01-09", due="2024-01-10", status="NEEDS-ACTION", rrule=Recur.from_rrule("FREQ=DAILY;COUNT=10"), ) ) # Mark instances as completed todo_store.edit("mock-uid-1", Todo(status="COMPLETED"), recurrence_id="20240109") # Delete all the items todo_store.delete("mock-uid-1") assert not calendar.todos def test_delete_instance_in_todo_series( calendar: Calendar, todo_store: TodoStore, fetch_todos: Callable[..., list[dict[str, Any]]], frozen_time: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test deleting a single instance of a recurring todo item.""" # Create a recurring to-do item todo_store.add( Todo( summary="Walk dog", dtstart="2024-01-09", due="2024-01-10", status="NEEDS-ACTION", rrule=Recur.from_rrule("FREQ=DAILY;COUNT=10"), ) ) raw_ids = [ (item.dtstart.isoformat(), item.recurrence_id, item.rrule) for item in calendar.todos ] assert raw_ids == snapshot # Mark instances as completed todo_store.edit("mock-uid-1", Todo(status="COMPLETED"), recurrence_id="20240109") raw_ids = [ (item.dtstart.isoformat(), item.recurrence_id, item.rrule, item.exdate) for item in calendar.todos ] assert raw_ids == snapshot # Delete a another instance todo_store.delete("mock-uid-1", recurrence_id="20240110") raw_ids = [ (item.dtstart.isoformat(), item.recurrence_id, item.rrule, item.exdate) for item in calendar.todos ] assert raw_ids == snapshot # Advance to the next day. frozen_time.move_to("2024-01-10T10:00:00") # Previous item is still marked completed and new item has not started yet assert fetch_todos(["uid", "recurrence_id", "due", "summary", "status"]) == snapshot # Advance to the next day and New item appears. frozen_time.move_to("2024-01-11T10:00:00") assert fetch_todos(["uid", "recurrence_id", "due", "summary", "status"]) == snapshot # Advance to the next day and New item appears. frozen_time.move_to("2024-01-12T10:00:00") assert fetch_todos(["uid", "recurrence_id", "due", "summary", "status"]) == snapshot def test_modify_todo_rrule_for_this_and_future( calendar: Calendar, todo_store: TodoStore, fetch_todos: Callable[..., list[dict[str, Any]]], frozen_time: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test modify an rrule in the middle of the series.""" # Create a recurring to-do item to wash the card every Saturday todo_store.add( Todo( summary="Wash car (Sa)", dtstart="2024-01-06", due="2024-01-07", status="NEEDS-ACTION", rrule=Recur.from_rrule("FREQ=WEEKLY;BYDAY=SA;COUNT=10"), ) ) # Move the item to Sunday going forward todo_store.edit( "mock-uid-1", Todo( summary="Wash car (Su)", dtstart="2024-01-21", due="2024-01-22", rrule=Recur.from_rrule("FREQ=WEEKLY;BYDAY=SU;COUNT=10"), ), recurrence_id="20240120", recurrence_range=Range.THIS_AND_FUTURE, ) assert IcsCalendarStream.calendar_to_ics(calendar) == snapshot(name="ics") for date in ("2024-01-05", "2024-01-12", "2024-01-19", "2024-01-26"): frozen_time.move_to(date) assert fetch_todos( ["uid", "recurrence_id", "due", "summary", "status"] ) == snapshot(name=date) def test_modify_todo_due_without_dtstart( calendar: Calendar, todo_store: TodoStore, ) -> None: """Validate that a due date modification without updating dtstart will be repaired.""" # Create a recurring to-do item to wash the card every Saturday todo_store.add( Todo( summary="Wash car", dtstart="2024-01-06", due="2024-01-07", ) ) # Move the due date to be before the dtstart and change to a datetime. todo_store.edit( "mock-uid-1", Todo( summary="Wash car", due="2024-01-01T10:00:00Z", ), ) todos = list(todo_store.todo_list()) assert len(todos) == 1 todo = todos[0] assert todo.due == datetime.datetime( 2024, 1, 1, 10, 0, 0, tzinfo=datetime.timezone.utc ) assert isinstance(todo.dtstart, datetime.datetime) assert todo.dtstart < todo.due @pytest.mark.parametrize( ("due", "expected_tz"), [ (None, TZ), ("2024-01-07T10:00:00Z", datetime.timezone.utc), ("2024-01-07T10:00:00-05:00", zoneinfo.ZoneInfo("America/New_York")), ], ) def test_dtstart_timezone( calendar: Calendar, todo_store: TodoStore, due: str | None, expected_tz: zoneinfo.ZoneInfo, ) -> None: """Validate that a due date modification without updating dtstart will be repaired.""" # Create a recurring to-do item to wash the card every Saturday todo_store.add( Todo( summary="Wash car", ) ) todos = list(todo_store.todo_list()) assert len(todos) == 1 todo = todos[0] assert todo.due is None assert todo.dtstart.tzinfo == TZ @pytest.mark.parametrize( ("calendar"), [ IcsCalendarStream.calendar_from_ics( pathlib.Path("tests/examples/testdata/store_edit_bugs.ics").read_text() ), ], ) def test_store_edit_year_overrun_edit_once( calendar: Calendar, store: EventStore, ) -> None: """Exercise a bug where the year gets overrun when editing an event. The bug was caused by not properly handling the timezone when editing a recurring event. """ assert len(calendar.events) == 1 viewer_tz = zoneinfo.ZoneInfo("America/New_York") calendar_tz = zoneinfo.ZoneInfo("Europe/Amsterdam") timeline = calendar.timeline_tz(tzinfo=viewer_tz) # Pick an arbitrary event in the series iter = timeline.active_after(datetime.datetime(2024, 10, 1, tzinfo=viewer_tz)) event1 = next(iter) assert event1.recurrence_id == "20241005T110000" assert event1.dtstart == datetime.datetime( 2024, 10, 5, 11, 0, 0, tzinfo=calendar_tz ) event2 = next(iter) assert event2.recurrence_id == "20241012T110000" assert event2.dtstart == datetime.datetime( 2024, 10, 12, 11, 0, 0, tzinfo=calendar_tz ) event3 = next(iter) assert event3.recurrence_id == "20241019T110000" assert event3.dtstart == datetime.datetime( 2024, 10, 19, 11, 0, 0, tzinfo=calendar_tz ) # Move event2 one hour earlier (9am in calendar tz) update_dtstart = event2.dtstart.astimezone(viewer_tz) assert update_dtstart == datetime.datetime(2024, 10, 12, 5, 0, 0, tzinfo=viewer_tz) update_dtstart -= datetime.timedelta(hours=1) store.edit( event2.uid, Event( dtstart=update_dtstart, end=update_dtstart + datetime.timedelta(hours=1), ), recurrence_id=event2.recurrence_id, recurrence_range=Range.NONE, ) # The edited event has its own entry in the calendar assert len(calendar.events) == 2 # Verify that event2 was updated to begin 1 hour earlier. timeline = calendar.timeline_tz(tzinfo=viewer_tz) iter = timeline.active_after(datetime.datetime(2024, 10, 1, tzinfo=viewer_tz)) event1 = next(iter) assert event1.recurrence_id == "20241005T110000" assert event1.dtstart == datetime.datetime( 2024, 10, 5, 11, 0, 0, tzinfo=calendar_tz ) event2 = next(iter) assert event2.recurrence_id == "20241012T110000" assert event2.dtstart == datetime.datetime( 2024, 10, 12, 10, 0, 0, tzinfo=calendar_tz ) event3 = next(iter) assert event3.recurrence_id == "20241019T110000" assert event3.dtstart == datetime.datetime( 2024, 10, 19, 11, 0, 0, tzinfo=calendar_tz ) event4 = next(iter) assert event4.recurrence_id == "20241026T110000" assert event4.dtstart == datetime.datetime( 2024, 10, 26, 11, 0, 0, tzinfo=calendar_tz ) # Edit all events after event3 update_dtstart = event3.dtstart.astimezone(viewer_tz) update_dtstart -= datetime.timedelta(minutes=30) store.edit( event3.uid, Event( dtstart=update_dtstart, end=update_dtstart + datetime.timedelta(hours=1), ), recurrence_id=event3.recurrence_id, recurrence_range=Range.THIS_AND_FUTURE, ) # The edited event has its own entry in the calendar assert len(calendar.events) == 3 # Verify event3 and beyond are now updated. timeline = calendar.timeline_tz(tzinfo=viewer_tz) iter = timeline.active_after(datetime.datetime(2024, 10, 1, tzinfo=viewer_tz)) event1 = next(iter) assert event1.recurrence_id == "20241005T110000" assert event1.dtstart == datetime.datetime( 2024, 10, 5, 11, 0, 0, tzinfo=calendar_tz ) event2 = next(iter) assert event2.recurrence_id == "20241012T110000" assert event2.dtstart == datetime.datetime( 2024, 10, 12, 10, 0, 0, tzinfo=calendar_tz ) event3 = next(iter) assert event3.recurrence_id == "20241019T043000" assert event3.dtstart == datetime.datetime( 2024, 10, 19, 10, 30, 0, tzinfo=calendar_tz ) event4 = next(iter) assert event4.recurrence_id == "20241026T043000" assert event4.dtstart == datetime.datetime( 2024, 10, 26, 10, 30, 0, tzinfo=calendar_tz ) @pytest.mark.parametrize( ("calendar"), [ IcsCalendarStream.calendar_from_ics( pathlib.Path("tests/examples/testdata/store_edit_bugs.ics").read_text() ), ], ) def test_store_edit_year_overrun_edit_this_and_future( calendar: Calendar, store: EventStore, ) -> None: """Exercise a bug where the year gets overrun when editing an event. The bug was caused by not properly handling the timezone when editing a recurring event. """ assert len(calendar.events) == 1 viewer_tz = zoneinfo.ZoneInfo("America/New_York") calendar_tz = zoneinfo.ZoneInfo("Europe/Amsterdam") timeline = calendar.timeline_tz(tzinfo=viewer_tz) # Pick an arbitrary event in the series iter = timeline.active_after(datetime.datetime(2024, 10, 1, tzinfo=viewer_tz)) event1 = next(iter) assert event1.recurrence_id == "20241005T110000" assert event1.dtstart == datetime.datetime( 2024, 10, 5, 11, 0, 0, tzinfo=calendar_tz ) event2 = next(iter) assert event2.recurrence_id == "20241012T110000" assert event2.dtstart == datetime.datetime( 2024, 10, 12, 11, 0, 0, tzinfo=calendar_tz ) event3 = next(iter) assert event3.recurrence_id == "20241019T110000" assert event3.dtstart == datetime.datetime( 2024, 10, 19, 11, 0, 0, tzinfo=calendar_tz ) # Move event2 one hour earlier (9am in calendar tz) update_dtstart = event2.dtstart.astimezone(viewer_tz) assert update_dtstart == datetime.datetime(2024, 10, 12, 5, 0, 0, tzinfo=viewer_tz) update_dtstart -= datetime.timedelta(hours=1) store.edit( event2.uid, Event( dtstart=update_dtstart, end=update_dtstart + datetime.timedelta(hours=1), ), recurrence_id=event2.recurrence_id, recurrence_range=Range.THIS_AND_FUTURE, ) store = EventStore(calendar) # The edited event has its own entry in the calendar assert len(calendar.events) == 2 # Verify that event2 was updated to begin 1 hour earlier. timeline = calendar.timeline_tz(tzinfo=viewer_tz) iter = timeline.active_after(datetime.datetime(2024, 10, 1, tzinfo=viewer_tz)) event1 = next(iter) assert event1.recurrence_id == "20241005T110000" assert event1.dtstart == datetime.datetime( 2024, 10, 5, 11, 0, 0, tzinfo=calendar_tz ) event2 = next(iter) assert event2.recurrence_id == "20241012T040000" assert event2.dtstart == datetime.datetime( 2024, 10, 12, 10, 0, 0, tzinfo=calendar_tz ) event3 = next(iter) assert event3.recurrence_id == "20241019T040000" assert event3.dtstart == datetime.datetime( 2024, 10, 19, 10, 0, 0, tzinfo=calendar_tz ) @pytest.mark.parametrize( ("calendar"), [ IcsCalendarStream.calendar_from_ics( pathlib.Path("tests/examples/testdata/store_edit_bugs.ics").read_text() ), ], ) def test_store_edit_year_override_set_floating_dates( calendar: Calendar, store: EventStore, ) -> None: """Exercise a bug where the year gets overrun when editing an event. This makes the edits using floating dates. """ assert len(calendar.events) == 1 viewer_tz = zoneinfo.ZoneInfo("America/New_York") calendar_tz = zoneinfo.ZoneInfo("Europe/Amsterdam") timeline = calendar.timeline_tz(tzinfo=viewer_tz) # Pick an arbitrary event in the series iter = timeline.active_after(datetime.datetime(2024, 10, 1, tzinfo=viewer_tz)) event1 = next(iter) assert event1.recurrence_id == "20241005T110000" assert event1.dtstart == datetime.datetime( 2024, 10, 5, 11, 0, 0, tzinfo=calendar_tz ) event2 = next(iter) assert event2.recurrence_id == "20241012T110000" assert event2.dtstart == datetime.datetime( 2024, 10, 12, 11, 0, 0, tzinfo=calendar_tz ) event3 = next(iter) assert event3.recurrence_id == "20241019T110000" assert event3.dtstart == datetime.datetime( 2024, 10, 19, 11, 0, 0, tzinfo=calendar_tz ) # Move event2 one hour earlier (9am in calendar tz) update_dtstart = event2.dtstart.astimezone(viewer_tz) assert update_dtstart == datetime.datetime( 2024, 10, 12, 11, 0, 0, tzinfo=calendar_tz ) assert update_dtstart == datetime.datetime(2024, 10, 12, 5, 0, 0, tzinfo=viewer_tz) update_dtstart -= datetime.timedelta(hours=1) update_dtstart = update_dtstart.replace(tzinfo=None) assert update_dtstart == datetime.datetime(2024, 10, 12, 4, 0, 0) store.edit( event2.uid, Event( dtstart=update_dtstart, end=update_dtstart + datetime.timedelta(hours=1), ), recurrence_id=event2.recurrence_id, recurrence_range=Range.NONE, ) # The edited event has its own entry in the calendar assert len(calendar.events) == 2 # Verify that event2 was updated to begin 1 hour earlier. timeline = calendar.timeline_tz(tzinfo=viewer_tz) iter = timeline.active_after(datetime.datetime(2024, 10, 1, tzinfo=viewer_tz)) event1 = next(iter) assert event1.recurrence_id == "20241005T110000" assert event1.dtstart == datetime.datetime( 2024, 10, 5, 11, 0, 0, tzinfo=calendar_tz ) event2 = next(iter) assert event2.recurrence_id == "20241012T110000" assert event2.dtstart == datetime.datetime( 2024, 10, 12, 4, 0, 0, ) event3 = next(iter) assert event3.recurrence_id == "20241019T110000" assert event3.dtstart == datetime.datetime( 2024, 10, 19, 11, 0, 0, tzinfo=calendar_tz ) event4 = next(iter) assert event4.recurrence_id == "20241026T110000" assert event4.dtstart == datetime.datetime( 2024, 10, 26, 11, 0, 0, tzinfo=calendar_tz ) # Edit all events after event3 update_dtstart = event3.dtstart.astimezone(viewer_tz) update_dtstart -= datetime.timedelta(minutes=30) update_dtstart = update_dtstart.replace(tzinfo=None) store.edit( event3.uid, Event( dtstart=update_dtstart, end=update_dtstart + datetime.timedelta(hours=1), ), recurrence_id=event3.recurrence_id, recurrence_range=Range.THIS_AND_FUTURE, ) # The edited event has its own entry in the calendar assert len(calendar.events) == 3 # Verify event3 and beyond are now updated. timeline = calendar.timeline_tz(tzinfo=viewer_tz) iter = timeline.active_after(datetime.datetime(2024, 10, 1, tzinfo=viewer_tz)) event1 = next(iter) assert event1.recurrence_id == "20241005T110000" assert event1.dtstart == datetime.datetime( 2024, 10, 5, 11, 0, 0, tzinfo=calendar_tz ) event2 = next(iter) assert event2.recurrence_id == "20241012T110000" assert event2.dtstart == datetime.datetime(2024, 10, 12, 4, 0, 0) event3 = next(iter) assert event3.recurrence_id == "20241019T043000" assert event3.dtstart == datetime.datetime(2024, 10, 19, 4, 30, 0) event4 = next(iter) assert event4.recurrence_id == "20241026T043000" assert event4.dtstart == datetime.datetime(2024, 10, 26, 4, 30, 0) allenporter-ical-fe8800b/tests/test_timeline.py000066400000000000000000000061161510550726100217540ustar00rootroot00000000000000"""Tests for the timeline library.""" import datetime import random import zoneinfo from typing import Any from unittest.mock import patch import pytest from ical.calendar import Calendar from ical.event import Event from ical.journal import Journal from ical.types.recur import Recur from ical.timeline import generic_timeline TZ = zoneinfo.ZoneInfo("America/Regina") @pytest.fixture(name="calendar") def fake_calendar(num_events: int, num_instances: int) -> Calendar: """Fixture for creating a fake calendar of items.""" cal = Calendar() for i in range(num_events): delta = datetime.timedelta(days=int(365 * random.random())) cal.events.append( Event( summary=f"Event {i}", start=datetime.date(2022, 2, 1) + delta, end=datetime.date(2000, 2, 2) + delta, rrule=Recur.from_rrule(f"FREQ=DAILY;COUNT={num_instances}"), ) ) return cal @pytest.mark.parametrize( "num_events,num_instances", [ (10, 10), (10, 100), (10, 1000), (100, 10), (100, 100), ], ) @pytest.mark.benchmark(min_rounds=1, cprofile=True, warmup=False) def test_benchmark_merged_iter( calendar: Calendar, num_events: int, num_instances: int, benchmark: Any ) -> None: """Add a benchmark for the merged iterator.""" def exhaust() -> int: nonlocal calendar return sum(1 for _ in calendar.timeline_tz(TZ)) result = benchmark(exhaust) assert result == num_events * num_instances def test_journal_timeline() -> None: """Test journal entries on a timeline.""" journal = Journal( summary="Example", start=datetime.date(2022, 8, 7), rrule=Recur.from_rrule("FREQ=DAILY;COUNT=3"), ) assert journal.recurring with ( patch( "ical.util.local_timezone", return_value=zoneinfo.ZoneInfo("America/Regina") ), patch( "ical.journal.local_timezone", return_value=zoneinfo.ZoneInfo("America/Regina"), ), ): timeline = generic_timeline([journal], TZ) assert list(timeline) == [ Journal.model_copy(journal, update={"recurrence_id": "20220807"}), Journal.model_copy( journal, update={ "dtstart": datetime.date(2022, 8, 8), "recurrence_id": "20220808", }, ), Journal.model_copy( journal, update={ "dtstart": datetime.date(2022, 8, 9), "recurrence_id": "20220809", }, ), ] assert list( timeline.overlapping(datetime.date(2022, 8, 7), datetime.date(2022, 8, 9)) ) == [ Journal.model_copy(journal, update={"recurrence_id": "20220807"}), Journal.model_copy( journal, update={ "dtstart": datetime.date(2022, 8, 8), "recurrence_id": "20220808", }, ), ] allenporter-ical-fe8800b/tests/test_timezone.py000066400000000000000000000147151510550726100220040ustar00rootroot00000000000000"""Tests for Free/Busy component.""" from __future__ import annotations from collections.abc import Generator import datetime import inspect import pytest from freezegun import freeze_time from ical.calendar import Calendar from ical.calendar_stream import IcsCalendarStream from ical.exceptions import CalendarParseError from ical.timezone import IcsTimezoneInfo, Observance, Timezone from ical.types import UtcOffset from ical.types.recur import Frequency, Recur, Weekday, WeekdayValue from ical.tzif.timezoneinfo import TimezoneInfoError TEST_RECUR = Recur( freq=Frequency.YEARLY, by_month=[10], by_day=[WeekdayValue(Weekday.SUNDAY, occurrence=-1)], until=datetime.datetime(2006, 10, 29, 6, 0, 0), ) def test_requires_subcompnent() -> None: """Test Timezone constructor.""" with pytest.raises( CalendarParseError, match=r"At least one standard or daylight.*" ): Timezone(tz_id="America/New_York") def test_daylight() -> None: """Test a Timezone object with a daylight observance.""" timezone = Timezone( tz_id="America/New_York", last_modified=datetime.datetime(2005, 8, 9, 5), daylight=[ Observance( start=datetime.datetime(1967, 10, 29, 2, 0, 0, 0), tz_offset_to=UtcOffset(datetime.timedelta(hours=-4)), tz_offset_from=UtcOffset(datetime.timedelta(hours=-5)), tz_name=["edt"], rrule=TEST_RECUR, ), ], ) assert len(timezone.daylight) == 1 assert timezone.daylight[0].tz_name == ["edt"] tz_info = IcsTimezoneInfo.from_timezone(timezone) value = datetime.datetime(1967, 10, 29, 1, 59, 0, 0, tzinfo=tz_info) assert not tz_info.tzname(value) assert not tz_info.utcoffset(value) assert not tz_info.dst(value) value = datetime.datetime(1967, 10, 29, 2, 00, 0, 0, tzinfo=tz_info) assert tz_info.tzname(value) == "edt" assert tz_info.utcoffset(value) == datetime.timedelta(hours=-4) assert tz_info.dst(value) == datetime.timedelta(hours=1) def test_timezone_observence_start_time_validation() -> None: """Verify that a start time must be in local time.""" with pytest.raises( CalendarParseError, match=r".*Start time must be in local time format*" ): Observance( start=datetime.datetime(1967, 10, 29, 2, tzinfo=datetime.timezone.utc), tz_offset_to=UtcOffset(datetime.timedelta(hours=-5)), tz_offset_from=UtcOffset(datetime.timedelta(hours=-4)), tz_name=["est"], rrule=TEST_RECUR, ) @freeze_time("2022-08-22 12:30:00") def test_from_tzif_timezoneinfo_with_dst( mock_prodid: Generator[None, None, None], ) -> None: """Verify a timezone created from a tzif timezone info with DST information.""" timezone = Timezone.from_tzif("America/New_York") calendar = Calendar() calendar.timezones.append(timezone) stream = IcsCalendarStream(calendars=[calendar]) assert stream.ics() == inspect.cleandoc( """ BEGIN:VCALENDAR PRODID:-//example//1.2.3 VERSION:2.0 BEGIN:VTIMEZONE TZID:America/New_York BEGIN:STANDARD DTSTART:20101107T020000 TZOFFSETTO:-0500 TZOFFSETFROM:-0400 RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11 TZNAME:EST END:STANDARD BEGIN:DAYLIGHT DTSTART:20100314T020000 TZOFFSETTO:-0400 TZOFFSETFROM:-0500 RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3 TZNAME:EDT END:DAYLIGHT END:VTIMEZONE END:VCALENDAR """ ) tz_info = IcsTimezoneInfo.from_timezone(timezone) value = datetime.datetime(2010, 11, 7, 1, 59, 0) assert tz_info.tzname(value) == "EDT" assert tz_info.utcoffset(value) == datetime.timedelta(hours=-4) assert tz_info.dst(value) == datetime.timedelta(hours=1) value = datetime.datetime(2010, 11, 7, 2, 0, 0) assert tz_info.tzname(value) == "EST" assert tz_info.utcoffset(value) == datetime.timedelta(hours=-5) assert tz_info.dst(value) == datetime.timedelta(hours=0) value = datetime.datetime(2011, 3, 13, 1, 59, 0) assert tz_info.tzname(value) == "EST" assert tz_info.utcoffset(value) == datetime.timedelta(hours=-5) assert tz_info.dst(value) == datetime.timedelta(hours=0) value = datetime.datetime(2011, 3, 14, 2, 0, 0) assert tz_info.tzname(value) == "EDT" assert tz_info.utcoffset(value) == datetime.timedelta(hours=-4) assert tz_info.dst(value) == datetime.timedelta(hours=1) @freeze_time("2022-08-22 12:30:00") def test_from_tzif_timezoneinfo_fixed_offset( mock_prodid: Generator[None, None, None], ) -> None: """Verify a timezone created from a tzif timezone info with a fixed offset""" timezone = Timezone.from_tzif("Asia/Tokyo") calendar = Calendar() calendar.timezones.append(timezone) stream = IcsCalendarStream(calendars=[calendar]) assert stream.ics() == inspect.cleandoc( """ BEGIN:VCALENDAR PRODID:-//example//1.2.3 VERSION:2.0 BEGIN:VTIMEZONE TZID:Asia/Tokyo BEGIN:STANDARD DTSTART:20100101T000000 TZOFFSETTO:+0900 TZOFFSETFROM:+0900 TZNAME:JST END:STANDARD END:VTIMEZONE END:VCALENDAR """ ) def test_invalid_tzif_key() -> None: """Test creating a timezone object from tzif data that does not exist.""" with pytest.raises(TimezoneInfoError, match=r"Unable to find timezone"): Timezone.from_tzif("invalid") @freeze_time("2022-08-22 12:30:00") def test_clear_old_dtstamp(mock_prodid: Generator[None, None, None]) -> None: """Verify a timezone created from a tzif timezone info with a fixed offset""" stream = IcsCalendarStream.from_ics( inspect.cleandoc(""" BEGIN:VCALENDAR PRODID:-//example//1.2.3 VERSION:2.0 BEGIN:VTIMEZONE DTSTAMP:20220822T123000 TZID:Asia/Tokyo BEGIN:STANDARD DTSTART:20100101T000000 TZOFFSETTO:+0900 TZOFFSETFROM:+0900 TZNAME:JST END:STANDARD END:VTIMEZONE END:VCALENDAR """) ) # DTSTAMP is omitted from the output assert stream.ics() == inspect.cleandoc(""" BEGIN:VCALENDAR PRODID:-//example//1.2.3 VERSION:2.0 BEGIN:VTIMEZONE TZID:Asia/Tokyo BEGIN:STANDARD DTSTART:20100101T000000 TZOFFSETTO:+0900 TZOFFSETFROM:+0900 TZNAME:JST END:STANDARD END:VTIMEZONE END:VCALENDAR """) allenporter-ical-fe8800b/tests/test_todo.py000066400000000000000000000325111510550726100211110ustar00rootroot00000000000000"""Tests for Todo component.""" from __future__ import annotations import datetime import zoneinfo import textwrap from typing import Any from unittest.mock import patch from freezegun import freeze_time import pytest from ical.exceptions import CalendarParseError from ical.todo import Todo from ical.types.recur import Recur from ical.calendar_stream import IcsCalendarStream _TEST_TZ = datetime.timezone(datetime.timedelta(hours=1)) def test_empty() -> None: """Test that in practice a Todo requires no fields.""" todo = Todo() assert not todo.summary def test_todo() -> None: """Test a valid Todo object.""" todo = Todo(summary="Example", due=datetime.date(2022, 8, 7)) assert todo.summary == "Example" assert todo.due == datetime.date(2022, 8, 7) def test_duration() -> None: """Test relationship between the due and duration fields.""" todo = Todo(start=datetime.date(2022, 8, 7), duration=datetime.timedelta(days=1)) assert todo.start assert todo.duration # Both due and Duration can't be set with pytest.raises( CalendarParseError, match="Failed to parse calendar TODO component: Value error, Only one of due or duration may be set.", ): Todo( start=datetime.date(2022, 8, 7), duration=datetime.timedelta(days=1), due=datetime.date(2022, 8, 8), ) # Duration requires start date with pytest.raises( CalendarParseError, match="^Failed to parse calendar TODO component: Value error, Duration requires that dtstart is specified$", ): Todo(duration=datetime.timedelta(days=1)) todo = Todo(start=datetime.date(2022, 8, 7), due=datetime.date(2022, 8, 8)) assert todo.start assert todo.due assert todo.start_datetime with patch( "ical.util.local_timezone", return_value=zoneinfo.ZoneInfo("America/Regina") ): assert todo.start_datetime.isoformat() == "2022-08-07T06:00:00+00:00" def test_dtstart_date_duration_hours_invalid(): """Test that a Todo with datetime as dtstart and duration with seconds or microseconds (in practice anything smaller than days) fails to validate""" with pytest.raises(CalendarParseError): Todo(dtstart=datetime.date(2022, 8, 7), duration=datetime.timedelta(hours=1)) def test_computed_duration_no_start_duration(): """Test that a Todo without start and due or duration takes a whole day""" todo = Todo() assert todo.computed_duration == datetime.timedelta(days=1) @pytest.mark.parametrize( ("params"), [ ({}), ( { "start": datetime.datetime(2022, 9, 6, 6, 0, 0), } ), ( { "due": datetime.datetime(2022, 9, 6, 6, 0, 0), } ), ( { "duration": datetime.timedelta(hours=1), } ), ( { "start": datetime.datetime(2022, 9, 6, 6, 0, 0), "due": datetime.datetime( 2022, 9, 7, 6, 0, 0, tzinfo=zoneinfo.ZoneInfo("America/Regina") ), } ), ( { "start": datetime.datetime( 2022, 9, 6, 6, 0, 0, tzinfo=zoneinfo.ZoneInfo("America/Regina") ), "due": datetime.datetime(2022, 9, 7, 6, 0, 0), # floating } ), ( { "duration": datetime.timedelta(hours=1), } ), ], ) def test_validate_rrule_required_fields(params: dict[str, Any]) -> None: """Test that a Todo with an rrule requires a dtstart.""" with pytest.raises(CalendarParseError): todo = Todo( summary="Todo 1", rrule=Recur.from_rrule("FREQ=WEEKLY;BYDAY=WE,MO,TU,TH,FR;COUNT=3"), **params, ) todo.as_rrule() def test_is_recurring() -> None: """Test that a Todo with an rrule requires a dtstart.""" todo = Todo( summary="Todo 1", rrule=Recur.from_rrule("FREQ=DAILY;COUNT=3"), dtstart="2024-02-02", due="2024-02-03", ) assert todo.recurring assert todo.computed_duration == datetime.timedelta(days=1) assert list(todo.as_rrule()) == [ datetime.date(2024, 2, 2), datetime.date(2024, 2, 3), datetime.date(2024, 2, 4), ] def test_timestamp_start_due() -> None: """Test a timespan of a Todo with a start and due date.""" todo = Todo( summary="Example", dtstart=datetime.date(2022, 8, 1), due=datetime.date(2022, 8, 7), ) with patch("ical.todo.local_timezone", return_value=zoneinfo.ZoneInfo("CET")): ts = todo.timespan assert ts.start.isoformat() == "2022-08-01T00:00:00+02:00" assert ts.end.isoformat() == "2022-08-07T00:00:00+02:00" ts = todo.timespan_of(zoneinfo.ZoneInfo("America/Regina")) assert ts.start.isoformat() == "2022-08-01T00:00:00-06:00" assert ts.end.isoformat() == "2022-08-07T00:00:00-06:00" def test_timespan_start_duration() -> None: """Test that duration is taken into account in timespan calculation""" duration = datetime.timedelta(hours=1) todo = Todo( dtstart=datetime.datetime(2025, 10, 27, 0, 0, 0, tzinfo=_TEST_TZ), duration=duration, ) timespan = todo.timespan assert timespan.start.isoformat() == "2025-10-27T00:00:00+01:00" assert timespan.end.isoformat() == "2025-10-27T01:00:00+01:00" assert timespan.duration == duration def test_timespan_start_date_duration() -> None: """Test that timestamp for todo with date-typed start and set due spans the whole day""" duration = datetime.timedelta(days=1) todo = Todo( dtstart=datetime.date(2025, 10, 27), duration=duration, ) timespan = todo.timespan_of(_TEST_TZ) assert timespan.start.isoformat() == "2025-10-27T00:00:00+01:00" assert timespan.end.isoformat() == "2025-10-28T00:00:00+01:00" assert timespan.duration == duration def test_timespan_missing_dtstart() -> None: """Test a timespan of a Todo without a dtstart.""" todo = Todo(summary="Example", due=datetime.date(2022, 8, 7)) with patch( "ical.todo.local_timezone", return_value=zoneinfo.ZoneInfo("Pacific/Honolulu") ): ts = todo.timespan assert ts.start.isoformat() == "2022-08-07T00:00:00-10:00" assert ts.end.isoformat() == "2022-08-07T00:00:00-10:00" ts = todo.timespan_of(zoneinfo.ZoneInfo("America/Regina")) assert ts.start.isoformat() == "2022-08-07T00:00:00-06:00" assert ts.end.isoformat() == "2022-08-07T00:00:00-06:00" def test_timespan_fallback() -> None: """Test a timespan of a Todo with no explicit dtstart and due date""" with ( freeze_time("2022-09-03T09:38:05", tz_offset=10), patch( "ical.todo.local_timezone", return_value=zoneinfo.ZoneInfo("Pacific/Honolulu"), ), ): todo = Todo(summary="Example") ts = todo.timespan assert ts.start.isoformat() == "2022-09-03T00:00:00-10:00" assert ts.end.isoformat() == "2022-09-04T00:00:00-10:00" with ( freeze_time("2022-09-03T09:38:05", tz_offset=10), patch( "ical.todo.local_timezone", return_value=zoneinfo.ZoneInfo("Pacific/Honolulu"), ), ): ts = todo.timespan_of(zoneinfo.ZoneInfo("America/Regina")) assert ts.start.isoformat() == "2022-09-03T00:00:00-06:00" assert ts.end.isoformat() == "2022-09-04T00:00:00-06:00" @pytest.mark.parametrize( ("due", "expected"), [ (datetime.date(2022, 9, 6), True), (datetime.date(2022, 9, 7), True), (datetime.date(2022, 9, 8), False), (datetime.date(2022, 9, 9), False), (datetime.datetime(2022, 9, 7, 6, 0, 0, tzinfo=_TEST_TZ), True), (datetime.datetime(2022, 9, 7, 12, 0, 0, tzinfo=_TEST_TZ), False), (datetime.datetime(2022, 9, 8, 6, 0, 0, tzinfo=_TEST_TZ), False), ], ) @freeze_time("2022-09-07T09:38:05", tz_offset=1) def test_is_due(due: datetime.date | datetime.datetime, expected: bool) -> None: """Test that a Todo is due.""" todo = Todo( summary="Example", due=due, ) assert todo.is_due(tzinfo=_TEST_TZ) == expected def test_is_due_default_timezone() -> None: """Test a Todo is due with the default timezone.""" todo = Todo( summary="Example", due=datetime.date(2022, 9, 6), ) assert todo.is_due() def test_repair_mismatched_due_date_and_dtstart() -> None: """The calendar store has a bug when the due date changes type without updating the start date.""" calendar = IcsCalendarStream.calendar_from_ics( textwrap.dedent( """\ BEGIN:VCALENDAR PRODID:-//example.io//todo 2.0//EN VERSION:2.0 BEGIN:VTODO DTSTAMP:20240310T151256 UID:85cce364-def0-11ee-a2a9-6045bde93490 CREATED:20240310T151156 DESCRIPTION:Modify DTSTART:20240310T151151Z DUE:20240318 LAST-MODIFIED:20240310T151256 SEQUENCE:2 STATUS:NEEDS-ACTION SUMMARY:Example END:VTODO END:VCALENDAR """ ) ) assert len(calendar.todos) == 1 assert calendar.todos[0].due == datetime.date(2024, 3, 18) assert calendar.todos[0].dtstart == datetime.date(2024, 3, 10) def test_repair_mismatched_due_datetime_and_dtstart() -> None: """The calendar store has a bug when the due date changes type without updating the start date.""" calendar = IcsCalendarStream.calendar_from_ics( textwrap.dedent( """\ BEGIN:VCALENDAR PRODID:-//example.io//todo 2.0//EN VERSION:2.0 BEGIN:VTODO DTSTAMP:20240310T151256 UID:85cce364-def0-11ee-a2a9-6045bde93490 CREATED:20240310T151156 DESCRIPTION:Modify DTSTART:20240310 DUE:20240318T151151Z LAST-MODIFIED:20240310T151256 SEQUENCE:2 STATUS:NEEDS-ACTION SUMMARY:Example END:VTODO END:VCALENDAR """ ) ) assert len(calendar.todos) == 1 assert calendar.todos[0].due == datetime.datetime( 2024, 3, 18, 15, 11, 51, tzinfo=datetime.timezone.utc ) assert calendar.todos[0].dtstart == datetime.datetime( 2024, 3, 10, 0, 0, 0, tzinfo=datetime.timezone.utc ) def test_repair_out_of_order_due_and_dtstart() -> None: """The calendar store has a bug when the due date changes type without updating the start date.""" calendar = IcsCalendarStream.calendar_from_ics( textwrap.dedent( """\ BEGIN:VCALENDAR PRODID:-//example.io//todo 2.0//EN VERSION:2.0 BEGIN:VTODO DTSTAMP:20240310T151256 UID:85cce364-def0-11ee-a2a9-6045bde93490 CREATED:20240310T151156 DESCRIPTION:Modify DTSTART:20240410 DUE:20240318 LAST-MODIFIED:20240310T151256 SEQUENCE:2 STATUS:NEEDS-ACTION SUMMARY:Example END:VTODO END:VCALENDAR """ ) ) assert len(calendar.todos) == 1 assert calendar.todos[0].due == datetime.date(2024, 3, 18) assert calendar.todos[0].dtstart == datetime.date(2024, 3, 17) @pytest.mark.parametrize( "dtstart, duration", ( ( datetime.datetime(2025, 10, 27, 0, 0, 0, tzinfo=_TEST_TZ), datetime.timedelta(hours=1), ), ( datetime.datetime(2025, 10, 27, 0, 0, 0, tzinfo=_TEST_TZ), datetime.timedelta(days=1), ), (datetime.date(2025, 10, 27), datetime.timedelta(days=1)), ), ) def test_computed_duration( dtstart: datetime.datetime | datetime.date, duration: datetime.timedelta ) -> None: """Test that computed_duration is the same as duration when set""" todo = Todo( dtstart=dtstart, duration=duration, ) assert todo.computed_duration == duration @pytest.mark.parametrize( "start, end", ( (datetime.date(2025, 10, 27), None), (None, datetime.date(2025, 10, 27)), (None, None), ), ) def test_default_computed_duration( start: datetime.datetime | datetime.date | None, end: datetime.datetime | datetime.date | None, ) -> None: """Test that computed_duration is no duration or start & end are defined""" todo = Todo( dtstart=start, due = end, ) assert todo.computed_duration == datetime.timedelta(days=1) def test_default_computed_duration_zero() -> None: """Test the default duration when no due or end time are set.""" start = datetime.datetime(2025, 10, 27, 0, 0, 0, tzinfo=_TEST_TZ) todo = Todo( dtstart=start, ) assert todo.end == start assert todo.computed_duration == datetime.timedelta() def test_default_end_date() -> None: """Test that when only start is set and it's a date, end is the next day""" start = datetime.date(2025, 10, 27) todo = Todo( dtstart=start, ) assert todo.end == start + datetime.timedelta(days=1) allenporter-ical-fe8800b/tests/testdata/000077500000000000000000000000001510550726100203425ustar00rootroot00000000000000allenporter-ical-fe8800b/tests/testdata/datetime_local.ics000066400000000000000000000003651510550726100240140ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:19980115T110500 UID:19970610T172345Z-AF23B2@example.com DTSTART:19980118T230000 DTEND:19980118T233000 SUMMARY:Bastille Day Party END:VEVENT END:VCALENDARallenporter-ical-fe8800b/tests/testdata/datetime_timezone.ics000066400000000000000000000004411510550726100245470ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:19970610T172345Z UID:19970610T172345Z-AF23B2@example.com DTSTART;TZID=America/New_York:19970714T133000 DTEND;TZID=America/New_York:19970714T140000 SUMMARY:Mid July check-in END:VEVENT END:VCALENDARallenporter-ical-fe8800b/tests/testdata/datetime_utc.ics000066400000000000000000000003701510550726100235110ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:19970610T172345Z UID:19970610T172345Z-AF23B2@example.com DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:Bastille Day Party END:VEVENT END:VCALENDARallenporter-ical-fe8800b/tests/testdata/datetime_vtimezone.ics000066400000000000000000000011771510550726100247440ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20230313T011226 UID:1e19f31b-c13c-11ed-8431-066a07ffbaf5 DTSTART;TZID=America/Example:20230312T181210 DTEND;TZID=America/Example:20230312T191210 SUMMARY:Event 1 CREATED:20230313T011226 SEQUENCE:0 END:VEVENT BEGIN:VTIMEZONE TZID:America/Example BEGIN:STANDARD DTSTART:20101107T020000 TZOFFSETTO:-0800 TZOFFSETFROM:-0700 RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11 TZNAME:PST END:STANDARD BEGIN:DAYLIGHT DTSTART:20100314T020000 TZOFFSETTO:-0700 TZOFFSETFROM:-0800 RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3 TZNAME:PDT END:DAYLIGHT END:VTIMEZONE END:VCALENDAR allenporter-ical-fe8800b/tests/testdata/datetime_vtimezone_plus.ics000066400000000000000000000011711510550726100260010ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20230313T011226 UID:1e19f31b-c13c-11ed-8431-066a07ffbaf5 DTSTART;TZID=Europe/Berlin:20230312T181210 DTEND;TZID=Europe/Berlin:20230312T191210 SUMMARY:Event 1 CREATED:20230313T011226 SEQUENCE:0 END:VEVENT BEGIN:VTIMEZONE TZID:Europe/Berlin BEGIN:STANDARD DTSTART:20101107T020000 TZOFFSETTO:+0200 TZOFFSETFROM:+0100 RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11 TZNAME:PST END:STANDARD BEGIN:DAYLIGHT DTSTART:20100314T020000 TZOFFSETTO:+0100 TZOFFSETFROM:+0200 RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3 TZNAME:PDT END:DAYLIGHT END:VTIMEZONE END:VCALENDAR allenporter-ical-fe8800b/tests/testdata/description_altrep.ics000066400000000000000000000005371510550726100247410ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:19970610T172345Z UID:19970610T172345Z-AF23B2@example.com DTSTART:19970714T170000Z DTEND:19970715T040000Z SUMMARY:Conference DESCRIPTION;ALTREP="cid:part1.0001@example.org":The Fall'98 Wild Wizards Conference - - Las Vegas\, NV\, USA END:VEVENT END:VCALENDARallenporter-ical-fe8800b/tests/testdata/duration_negative.ics000066400000000000000000000007311510550726100245520ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20070423T123432Z UID:20070423T123432Z-541111@example.com DTSTART;VALUE=DATE:20070628 DTEND;VALUE=DATE:20070709 SUMMARY:Festival International de Jazz de Montreal TRANSP:TRANSPARENT BEGIN:VALARM TRIGGER;VALUE=DATE-TIME:19970317T133000Z REPEAT:4 DURATION:-P1W6DT15H ACTION:AUDIO ATTACH;FMTTYPE=audio/basic:ftp://example.com/pub/sounds/bell-01.aud END:VALARM END:VEVENT END:VCALENDAR allenporter-ical-fe8800b/tests/testdata/empty.ics000066400000000000000000000000001510550726100221660ustar00rootroot00000000000000allenporter-ical-fe8800b/tests/testdata/event_all_day.ics000066400000000000000000000004511510550726100236500ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20070423T123432Z UID:20070423T123432Z-541111@example.com DTSTART;VALUE=DATE:20070628 DTEND;VALUE=DATE:20070709 SUMMARY:Festival International de Jazz de Montreal TRANSP:TRANSPARENT END:VEVENT END:VCALENDARallenporter-ical-fe8800b/tests/testdata/event_attendee.ics000066400000000000000000000010111510550726100240250ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20070423T123432Z UID:20070423T123432Z-541111@example.com DTSTART:20070628 DTEND:20070709 SUMMARY:Festival International de Jazz de Montreal ATTENDEE;MEMBER="mailto:DEV-GROUP@example.com": mailto:joecool@example.com ATTENDEE;DELEGATED-FROM="mailto:immud@example.com": mailto:ildoit@example.com ORGANIZER;CN=John Smith:mailto:jsmith@example.com REQUEST-STATUS:3.1;Invalid property value;DTSTART:96-Apr-01 END:VEVENT END:VCALENDARallenporter-ical-fe8800b/tests/testdata/event_cal_address.ics000066400000000000000000000017441510550726100245150ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20070423T123432Z UID:20070423T123432Z-541111@example.com DTSTART:20070628 DTEND:20070709 SUMMARY:Festival International de Jazz de Montreal ATTENDEE;CUTYPE=GROUP:mailto:ietf-calsch@example.org ATTENDEE;DELEGATED-FROM="mailto:jsmith@example.com":mailto:jdoe@example.com ATTENDEE;DELEGATED-TO="mailto:jdoe@example.com","mailto:jqpublic@example.co m":mailto:jsmith@example.com ATTENDEE;MEMBER="mailto:ietf-calsch@example.org":mailto:jsmith@example.com ATTENDEE;MEMBER="mailto:projectA@example.com","mailto:projectB@example.com" :mailto:janedoe@example.com ATTENDEE;PARTSTAT=DECLINED:mailto:jsmith@example.com ATTENDEE;ROLE=CHAIR:mailto:mrbig@example.com ATTENDEE;RSVP=TRUE:mailto:jsmith@example.com ATTENDEE;SENT-BY="mailto:sray@example.com":mailto:jsmith@example.com ORGANIZER;DIR="ldap://example.com:6666/o=ABC%20Industries,c=US???(cn=Jim%20 Dolittle)":mailto:jimdo@example.com END:VEVENT END:VCALENDARallenporter-ical-fe8800b/tests/testdata/event_multi_day.ics000066400000000000000000000006611510550726100242350ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:19960704T120000Z UID:uid1@example.com ORGANIZER:mailto:jsmith@example.com DTSTART:19960918T143000Z DTEND:19960920T220000Z SUMMARY:Networld+Interop Conference CATEGORIES:CONFERENCE DESCRIPTION:Networld+Interop Conference and Exhibit\nAtlanta World Congress Center\n Atlanta\, Georgia STATUS:CONFIRMED END:VEVENT END:VCALENDARallenporter-ical-fe8800b/tests/testdata/event_priority.ics000066400000000000000000000004151510550726100241240ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20070514T103211Z UID:20070514T103211Z-123404@example.com DTSTART:20070514T110000Z DTEND:20070514T113000Z SUMMARY:Client meeting PRIORITY:1 END:VEVENT END:VCALENDARallenporter-ical-fe8800b/tests/testdata/event_properties.ics000066400000000000000000000013011510550726100244320ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20070423T123432Z UID:20070423T123432Z-541111@example.com DTSTART:20070628T090000 DTEND:20070628T100000 SUMMARY:Customer Meeting CLASS:PRIVATE COMMENT:The meeting really needs to include both ourselves and the customer. We can't hold this meeting without them. As a matter of fact\, the venue for the meeting ought to be at their site. - - John DESCRIPTION:Meeting to provide technical review for "Phoenix" design.\nHappy Face Conference Room. Phoenix design team MUST attend this meeting.\nRSVP to team leader. GEO:37.386013;-122.082932 LOCATION:Conference Room - F123\, Bldg. 002 END:VEVENT END:VCALENDARallenporter-ical-fe8800b/tests/testdata/event_resources.ics000066400000000000000000000005341510550726100242570ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20070423T123432Z UID:20070423T123432Z-541111@example.com DTSTART:20070628T090000 DTEND:20070628T100000 SUMMARY:Customer Meeting RESOURCES:EASEL,PROJECTOR,VCR RESOURCES;LANGUAGE=fr:Nettoyeur haute pression REQUEST-STATUS:2.0;Success END:VEVENT END:VCALENDARallenporter-ical-fe8800b/tests/testdata/event_uri.ics000066400000000000000000000005621510550726100230450ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//RDU Software//NONSGML HandCal//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:19980313T060000Z UID:19970610T172345Z-AF23B2@example.com DTSTART:19980313T141711Z DTEND:19980410T141711Z CONTACT:Jim Dolittle\, ABC Industries\, +1-919-555-1234 ORGANIZER:mailto:jsmith@example.com URL:http://www.example.com/calendar/busytime/jsmith.ifb END:VEVENT END:VCALENDARallenporter-ical-fe8800b/tests/testdata/freebusy.ics000066400000000000000000000014001510550726100226610ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//RDU Software//NONSGML HandCal//EN VERSION:2.0 BEGIN:VFREEBUSY DTSTAMP:19970610T172345Z UID:19970610T172345Z-AF23B2@example.com DTSTART:19980313T141711Z DTEND:19980410T141711Z FREEBUSY:19980314T233000Z/19980315T003000Z FREEBUSY:19980316T153000Z/19980316T163000Z FREEBUSY:19980318T030000Z/19980318T040000Z ORGANIZER:mailto:jsmith@example.com URL:http://www.example.com/calendar/busytime/jsmith.ifb END:VFREEBUSY BEGIN:VFREEBUSY DTSTAMP:19970901T130000Z UID:19970901T130000Z-123401@example.com FREEBUSY;FBTYPE=BUSY-UNAVAILABLE:19970308T160000Z/PT8H30M FREEBUSY;FBTYPE=FREE:19970308T160000Z/PT3H,19970308T200000Z/PT1H FREEBUSY;FBTYPE=FREE:19970308T160000Z/PT3H,19970308T200000Z/PT1H ,19970308T230000Z/19970309T000000Z END:VFREEBUSY END:VCALENDARallenporter-ical-fe8800b/tests/testdata/iana_property_boolean.ics000066400000000000000000000004361510550726100254200ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:19960704T120000Z UID:uid1@example.com DTSTART:19960918T143000Z DTEND:19960920T220000Z SUMMARY:Event Summary DRESSCODE:CASUAL NON-SMOKING;VALUE=BOOLEAN:TRUE END:VEVENT END:VCALENDARallenporter-ical-fe8800b/tests/testdata/journal_entry.ics000066400000000000000000000013151510550726100237350ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//ABC Corporation//NONSGML My Product//EN BEGIN:VJOURNAL UID:19970901T130000Z-123405@example.com DTSTAMP:19970901T130000Z DTSTART;VALUE=DATE:19970317 SUMMARY:Staff meeting minutes DESCRIPTION:1. Staff meeting: Participants include Joe\, Lisa\, and Bob. Aurora project plans were reviewed. There is currently no budget reserves for this project. Lisa will escalate to management. Next meeting on Tuesday.\n 2. Telephone Conference: ABC Corp. sales representative called to discuss new printer. Promised to get us a demo by Friday.\n3. Henry Miller (Handsoff Insurance): Car was totaled by tree. Is looking into a loaner car. 555-2323 (tel). END:VJOURNAL END:VCALENDARallenporter-ical-fe8800b/tests/testdata/journal_properties.ics000066400000000000000000000004141510550726100247670ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//ABC Corporation//NONSGML My Product//EN BEGIN:VJOURNAL UID:19970901T130000Z-123405@example.com DTSTAMP:19970901T130000Z DTSTART;VALUE=DATE:19970317 SUMMARY:Staff meeting minutes STATUS:FINAL CLASS:PUBLIC END:VJOURNAL END:VCALENDARallenporter-ical-fe8800b/tests/testdata/related_to.ics000066400000000000000000000007571510550726100231750ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VTODO UID:20070313T123432Z-456553@example.com DTSTAMP:20070313T123432Z DUE;VALUE=DATE:20070501 SUMMARY:Submit Quebec Income Tax Return for 2006 CLASS:CONFIDENTIAL CATEGORIES:FAMILY,FINANCE STATUS:NEEDS-ACTION END:VTODO BEGIN:VTODO UID:20070313T123432Z-456554@example.com DTSTAMP:20070313T123432Z SUMMARY:Buy pens STATUS:NEEDS-ACTION RELATED-TO;RELTYPE=PARENT:20070313T123432Z-456553@example.com END:VTODO END:VCALENDARallenporter-ical-fe8800b/tests/testdata/related_to_default.ics000066400000000000000000000007401510550726100246710ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VTODO UID:20070313T123432Z-456553@example.com DTSTAMP:20070313T123432Z DUE;VALUE=DATE:20070501 SUMMARY:Submit Quebec Income Tax Return for 2006 CLASS:CONFIDENTIAL CATEGORIES:FAMILY,FINANCE STATUS:NEEDS-ACTION END:VTODO BEGIN:VTODO UID:20070313T123432Z-456554@example.com DTSTAMP:20070313T123432Z SUMMARY:Buy pens STATUS:NEEDS-ACTION RELATED-TO:20070313T123432Z-456553@example.com END:VTODO END:VCALENDARallenporter-ical-fe8800b/tests/testdata/rrule-daily.ics000066400000000000000000000006701510550726100232760ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTART;TZID=America/Los_Angeles:20220802T090000 DTEND;TZID=America/Los_Angeles:20220802T093000 DTSTAMP:20220731T190408Z UID:5gog4qp8rohrj69q63ddvbnbt5@google.com RRULE:FREQ=DAILY CREATED:20220731T190207Z DESCRIPTION: LAST-MODIFIED:20220731T190207Z LOCATION: SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Morning Daily Exercise TRANSP:OPAQUE END:VEVENT END:VCALENDARallenporter-ical-fe8800b/tests/testdata/rrule-date.ics000066400000000000000000000005741510550726100231140ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTART:20220802 DTEND:20220803 DTSTAMP:20220731T190408Z UID:5gog4qp8rohrj69q63ddvbnbt5@google.com RRULE:FREQ=DAILY;UNTIL=20220904 CREATED:20220731T190207Z DESCRIPTION: LAST-MODIFIED:20220731T190207Z LOCATION: SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Daily Event TRANSP:OPAQUE END:VEVENT END:VCALENDARallenporter-ical-fe8800b/tests/testdata/rrule-exdate-mismatch.ics000066400000000000000000000004561510550726100252530ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20231210T163427 UID:fc7a9b1e-9779-11ee-8e0d-6045bda9e0cd DTSTART:20231224 DTEND:20231224 SUMMARY:Example date RRULE:FREQ=WEEKLY;BYDAY=SU,SA EXDATE:20231223T080000 EXDATE:20231217T080000 END:VEVENT END:VCALENDARallenporter-ical-fe8800b/tests/testdata/rrule-exdate.ics000066400000000000000000000005111510550726100234400ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTART;VALUE=DATE:20220801 DTEND;VALUE=DATE:20220802 RRULE:FREQ=MONTHLY;BYMONTHDAY=1 EXDATE:20220901,20221001 EXDATE:20230101 DTSTAMP:20220731T190408Z UID:2b83520vueebk0muv6osv1qci6@google.com SUMMARY:First of the month END:VEVENT END:VCALENDARallenporter-ical-fe8800b/tests/testdata/rrule-monthly.ics000066400000000000000000000007001510550726100236600ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTART;VALUE=DATE:20220801 DTEND;VALUE=DATE:20220802 RRULE:FREQ=MONTHLY;BYMONTHDAY=1 DTSTAMP:20220731T190408Z UID:2b83520vueebk0muv6osv1qci6@google.com CREATED:20220731T190327Z DESCRIPTION: LAST-MODIFIED:20220731T190339Z LOCATION: SEQUENCE:0 STATUS:CONFIRMED SUMMARY:First of the month TRANSP:TRANSPARENT RECURRENCE-ID;VALUE=DATE:20220901 END:VEVENT END:VCALENDARallenporter-ical-fe8800b/tests/testdata/rrule-until-mismatch.ics000066400000000000000000000006041510550726100251270ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTART:20220802 DTEND:20220803 DTSTAMP:20220731T190408Z UID:5gog4qp8rohrj69q63ddvbnbt5@google.com RRULE:FREQ=DAILY;UNTIL=20220904T070000Z CREATED:20220731T190207Z DESCRIPTION: LAST-MODIFIED:20220731T190207Z LOCATION: SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Daily Event TRANSP:OPAQUE END:VEVENT END:VCALENDARallenporter-ical-fe8800b/tests/testdata/rrule-weekly.ics000066400000000000000000000006271510550726100234760ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT DTSTART;VALUE=DATE:20220804 DTEND;VALUE=DATE:20220805 RRULE:FREQ=WEEKLY;BYDAY=TH DTSTAMP:20220731T190408Z UID:41bqf3it4r8kgquptq22nhj3pt@google.com CREATED:20220731T190240Z DESCRIPTION: LAST-MODIFIED:20220731T190251Z LOCATION: SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Weekly Trash Day TRANSP:TRANSPARENT END:VEVENT END:VCALENDARallenporter-ical-fe8800b/tests/testdata/rrule-yearly.ics000066400000000000000000000005231510550726100234760ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VEVENT UID:19970901T130000Z-123403@example.com DTSTAMP:19970901T130000Z DTSTART;VALUE=DATE:19971102 SUMMARY:Our Blissful Anniversary TRANSP:TRANSPARENT CLASS:CONFIDENTIAL CATEGORIES:ANNIVERSARY,PERSONAL,SPECIAL OCCASION RRULE:FREQ=YEARLY END:VEVENT END:VCALENDARallenporter-ical-fe8800b/tests/testdata/timezone_ny.ics000066400000000000000000000022771510550726100234120ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//ABC Corporation//NONSGML My Product//EN VERSION:2.0 BEGIN:VTIMEZONE TZID:America/New_York LAST-MODIFIED:20050809T050000Z BEGIN:DAYLIGHT DTSTART:19670430T020000 RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19730429T070000Z TZOFFSETFROM:-0500 TZOFFSETTO:-0400 TZNAME:EDT END:DAYLIGHT BEGIN:STANDARD DTSTART:19671029T020000 RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=20061029T060000Z TZOFFSETFROM:-0400 TZOFFSETTO:-0500 TZNAME:EST END:STANDARD BEGIN:DAYLIGHT DTSTART:19740106T020000 RDATE:19750223T020000 TZOFFSETFROM:-0500 TZOFFSETTO:-0400 TZNAME:EDT END:DAYLIGHT BEGIN:DAYLIGHT DTSTART:19760425T020000 RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19860427T070000Z TZOFFSETFROM:-0500 TZOFFSETTO:-0400 TZNAME:EDT END:DAYLIGHT BEGIN:DAYLIGHT DTSTART:19870405T020000 RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;UNTIL=20060402T070000Z TZOFFSETFROM:-0500 TZOFFSETTO:-0400 TZNAME:EDT END:DAYLIGHT BEGIN:DAYLIGHT DTSTART:20070311T020000 RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU TZOFFSETFROM:-0500 TZOFFSETTO:-0400 TZNAME:EDT END:DAYLIGHT BEGIN:STANDARD DTSTART:20071104T020000 RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU TZOFFSETFROM:-0400 TZOFFSETTO:-0500 TZNAME:EST END:STANDARD END:VTIMEZONE END:VCALENDAR allenporter-ical-fe8800b/tests/testdata/todo-invalid-dtstart-tzid.ics000066400000000000000000000015041510550726100260660ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VTODO UID:20070313T123432Z-456553@example.com DTSTAMP:20070313T123432Z SUMMARY:Submit Quebec Income Tax Return for 2006 DTSTART;TZID=CST:20070501T110000 DUE;TZID=America/Los_Angeles:20070501T110000 CLASS:CONFIDENTIAL CATEGORIES:FAMILY,FINANCE STATUS:NEEDS-ACTION END:VTODO BEGIN:VTODO UID:20070514T103211Z-123404@example.com DTSTAMP:20070514T103211Z SUMMARY:Submit Revised Internet-Draft DTSTART;TZID=CST:20070514T110000 DUE:20070709T130000Z COMPLETED:20070707T100000Z PRIORITY:1 STATUS:NEEDS-ACTION END:VTODO BEGIN:VTODO UID:20070313T123432Z-456553@example.com DTSTAMP:20070313T123432Z SUMMARY:Floating due datetime DTSTART;TZID=CST:20070501T110000 DUE:20070501T110000 CLASS:CONFIDENTIAL CATEGORIES:FAMILY,FINANCE STATUS:NEEDS-ACTION END:VTODO END:VCALENDAR allenporter-ical-fe8800b/tests/testdata/todo.ics000066400000000000000000000010321510550726100220030ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//hacksw/handcal//NONSGML v1.0//EN VERSION:2.0 BEGIN:VTODO UID:20070313T123432Z-456553@example.com DTSTAMP:20070313T123432Z SUMMARY:Submit Quebec Income Tax Return for 2006 DUE;VALUE=DATE:20070501 CLASS:CONFIDENTIAL CATEGORIES:FAMILY,FINANCE STATUS:NEEDS-ACTION END:VTODO BEGIN:VTODO UID:20070514T103211Z-123404@example.com DTSTAMP:20070514T103211Z SUMMARY:Submit Revised Internet-Draft DTSTART:20070514T110000Z DUE:20070709T130000Z COMPLETED:20070707T100000Z PRIORITY:1 STATUS:NEEDS-ACTION END:VTODO END:VCALENDARallenporter-ical-fe8800b/tests/testdata/todo_valarm.ics000066400000000000000000000007551510550726100233600ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//ABC Corporation//NONSGML My Product//EN VERSION:2.0 BEGIN:VTODO DTSTAMP:19980130T134500Z SEQUENCE:2 UID:uid4@example.com ORGANIZER:mailto:unclesam@example.com ATTENDEE;PARTSTAT=ACCEPTED:mailto:jqpublic@example.com DUE:19980415T000000 STATUS:NEEDS-ACTION SUMMARY:Submit Income Taxes BEGIN:VALARM ACTION:AUDIO TRIGGER:19980403T120000Z ATTACH;FMTTYPE=audio/basic:http://example.com/pub/audio- files/ssbanner.aud REPEAT:4 DURATION:PT1H END:VALARM END:VTODO END:VCALENDARallenporter-ical-fe8800b/tests/types/000077500000000000000000000000001510550726100176755ustar00rootroot00000000000000allenporter-ical-fe8800b/tests/types/test_boolean.py000066400000000000000000000023231510550726100227250ustar00rootroot00000000000000"""Tests for BOOLEAN data types.""" import pytest from ical.component import ComponentModel from ical.exceptions import CalendarParseError from ical.parsing.property import ParsedProperty class FakeModel(ComponentModel): """Model under test.""" example: bool def test_bool() -> None: """Test for boolean fields.""" model = FakeModel.model_validate( {"example": [ParsedProperty(name="example", value="TRUE")]} ) assert model.example model = FakeModel.model_validate( {"example": [ParsedProperty(name="example", value="FALSE")]} ) assert not model.example with pytest.raises(CalendarParseError): FakeModel.model_validate( {"example": [ParsedProperty(name="example", value="efd")]} ) # Populate based on bool object model = FakeModel(example=True) assert model.example component = model.__encode_component_root__() assert component.properties == [ ParsedProperty(name="example", value="TRUE"), ] model = FakeModel(example=False) assert not model.example component = model.__encode_component_root__() assert component.properties == [ ParsedProperty(name="example", value="FALSE"), ] allenporter-ical-fe8800b/tests/types/test_cal_address.py000066400000000000000000000040231510550726100235510ustar00rootroot00000000000000"""Tests for RELATED-TO data types.""" from pydantic import field_serializer import pytest from ical.exceptions import CalendarParseError from ical.component import ComponentModel from ical.parsing.component import ParsedComponent from ical.parsing.property import ParsedProperty, ParsedPropertyParameter from ical.types import CalAddress, Role from ical.types.data_types import serialize_field class FakeModel(ComponentModel): """Model under test.""" example: CalAddress serialize_fields = field_serializer("*")(serialize_field) # type: ignore[pydantic-field] def test_caladdress_role() -> None: """Test for no explicit reltype specified.""" model = FakeModel.model_validate( { "example": [ ParsedProperty( name="attendee", value="mailto:mrbig@example.com", params=[ ParsedPropertyParameter(name="ROLE", values=["CHAIR"]), ParsedPropertyParameter(name="CUTYPE", values=["INDIVIDUAL"]), ], ) ] }, ) assert model.example assert model.example.uri == "mailto:mrbig@example.com" assert model.example.role == Role.CHAIR assert model.example.user_type == "INDIVIDUAL" def test_caladdress_role_parse_failure() -> None: """Test for no explicit reltype specified.""" model = FakeModel.model_validate( { "example": [ ParsedProperty( name="attendee", value="mailto:mrbig@example.com", params=[ ParsedPropertyParameter(name="ROLE", values=["OTHER-ROLE"]), ParsedPropertyParameter(name="CUTYPE", values=["OTHER-CUTYPE"]), ], ) ] }, ) assert model.example assert model.example.uri == "mailto:mrbig@example.com" assert model.example.role == "OTHER-ROLE" assert model.example.user_type == "OTHER-CUTYPE" allenporter-ical-fe8800b/tests/types/test_date.py000066400000000000000000000030761510550726100222310ustar00rootroot00000000000000"""Tests for DATE values.""" import datetime from typing import Union import pytest from ical.exceptions import CalendarParseError from ical.component import ComponentModel from ical.parsing.property import ParsedProperty def test_date_parser() -> None: """Test for a date property value.""" class TestModel(ComponentModel): """Model under test.""" d: datetime.date model = TestModel.model_validate( { "d": [ParsedProperty(name="d", value="20220724")], } ) assert model.d == datetime.date(2022, 7, 24) with pytest.raises(CalendarParseError): TestModel.model_validate( { "d": [ ParsedProperty( name="dt", value="invalid-value", ) ], } ) # Build from an object model = TestModel(d=datetime.date(2022, 7, 20)) assert model.d.isoformat() == "2022-07-20" def test_union_date_parser() -> None: """Test for a union of multiple date property values.""" class TestModel(ComponentModel): """Model under test.""" d: Union[datetime.datetime, datetime.date] model = TestModel.model_validate( { "d": [ParsedProperty(name="d", value="20220724")], } ) assert model.d == datetime.date(2022, 7, 24) model = TestModel.model_validate( { "d": [ParsedProperty(name="d", value="20220724T120000")], } ) assert model.d == datetime.datetime(2022, 7, 24, 12, 0, 0) allenporter-ical-fe8800b/tests/types/test_date_time.py000066400000000000000000000111741510550726100232450ustar00rootroot00000000000000"""Tests for DATE-TIME values.""" import datetime from typing import Union from pydantic import field_serializer import pytest from ical.exceptions import CalendarParseError from ical.component import ComponentModel from ical.parsing.component import ParsedComponent from ical.parsing.property import ParsedProperty, ParsedPropertyParameter from ical.types.data_types import serialize_field from ical.tzif import timezoneinfo def test_datedatime_parser() -> None: """Test for a datetime property value.""" class TestModel(ComponentModel): """Model under test.""" dt: datetime.datetime model = TestModel.model_validate( { "dt": [ParsedProperty(name="dt", value="20220724T120000")], } ) assert model.dt == datetime.datetime(2022, 7, 24, 12, 0, 0) # Build from an object model = TestModel(dt=datetime.datetime(2022, 7, 20, 13, 0, 0)) assert model.dt.isoformat() == "2022-07-20T13:00:00" def test_datedatime_value_parser() -> None: """Test a datetime with a property parameter value.""" class TestModel(ComponentModel): """Model under test.""" dt: Union[datetime.datetime, datetime.date] model = TestModel.model_validate( { "dt": [ ParsedProperty( name="dt", value="20220724T120000", params=[ ParsedPropertyParameter(name="VALUE", values=["DATE-TIME"]), ], ) ], } ) assert model.dt == datetime.datetime(2022, 7, 24, 12, 0, 0) model = TestModel.model_validate( { "dt": [ ParsedProperty( name="dt", value="20220724", params=[ ParsedPropertyParameter(name="VALUE", values=["DATE"]), ], ) ], } ) assert model.dt == datetime.date(2022, 7, 24) with pytest.raises(CalendarParseError): TestModel.model_validate( { "dt": [ ParsedProperty( name="dt", value="20220724T120000", params=[ ParsedPropertyParameter(name="VALUE", values=["INVALID"]), ], ) ], } ) with pytest.raises(CalendarParseError): TestModel.model_validate( { "dt": [ ParsedProperty( name="dt", value="20220724", params=[ ParsedPropertyParameter(name="VALUE", values=["INVALID"]), ], ) ], } ) def test_datedatime_parameter_encoder() -> None: """Test a datetime with a property parameter encoded in the output.""" class TestModel(ComponentModel): """Model under test.""" dt: datetime.datetime serialize_fields = field_serializer("*")(serialize_field) # type: ignore[pydantic-field] model = TestModel.model_validate( { "dt": [ ParsedProperty( name="dt", value="20220724T120000", params=[ ParsedPropertyParameter( name="TZID", values=["America/New_York"] ), ], ) ], } ) assert model.dt == datetime.datetime( 2022, 7, 24, 12, 0, 0, tzinfo=timezoneinfo.read_tzinfo("America/New_York") ) assert model.__encode_component_root__() == ParsedComponent( name="TestModel", properties=[ ParsedProperty( name="dt", value="20220724T120000", params=[ ParsedPropertyParameter(name="TZID", values=["America/New_York"]) ], ) ], ) with pytest.raises(CalendarParseError, match="valid timezone"): TestModel.model_validate( { "dt": [ ParsedProperty( name="dt", value="20220724T120000", params=[ ParsedPropertyParameter( name="TZID", values=["Example"], ), ], ) ], } ) allenporter-ical-fe8800b/tests/types/test_duration.py000066400000000000000000000032731510550726100231400ustar00rootroot00000000000000"""Tests for DURATION data types.""" import datetime from pydantic import field_serializer import pytest from ical.component import ComponentModel from ical.parsing.property import ParsedProperty from ical.types.data_types import serialize_field class FakeModel(ComponentModel): """Model under test.""" duration: datetime.timedelta serialize_fields = field_serializer("*")(serialize_field) # type: ignore[pydantic-field] @pytest.mark.parametrize( "value,duration,encoded_value", [ ( "P15DT5H0M20S", datetime.timedelta(days=15, hours=5, seconds=20), "P2W1DT5H20S", ), ("P7W", datetime.timedelta(days=7 * 7), "P7W"), ("-P7W", datetime.timedelta(days=-7 * 7), "-P7W"), ("-P1W6DT15H", datetime.timedelta(days=-(7 + 6), hours=-15), "-P1W6DT15H"), ], ) def test_duration(value: str, duration: datetime.timedelta, encoded_value: str) -> None: """Test for duration fields.""" model = FakeModel.model_validate( {"duration": [ParsedProperty(name="duration", value=value)]} ) assert model.duration == duration component = model.__encode_component_root__() assert component.name == "FakeModel" assert component.properties == [ ParsedProperty(name="duration", value=encoded_value) ] def test_duration_from_object() -> None: """Test for a duration field from a native object.""" model = FakeModel(duration=datetime.timedelta(hours=1)) assert model.duration == datetime.timedelta(hours=1) component = model.__encode_component_root__() assert component.name == "FakeModel" assert component.properties == [ParsedProperty(name="duration", value="PT1H")] allenporter-ical-fe8800b/tests/types/test_float.py000066400000000000000000000016341510550726100224170ustar00rootroot00000000000000"""Tests for FLOAT data types.""" import pytest from ical.exceptions import CalendarParseError from ical.component import ComponentModel from ical.parsing.property import ParsedProperty class FakeModel(ComponentModel): """Model under test.""" example: list[float] def test_float() -> None: """Test for float fields.""" model = FakeModel.model_validate( { "example": [ ParsedProperty(name="example", value="45"), ParsedProperty(name="example", value="-46.2"), ParsedProperty(name="example", value="+47.32"), ] } ) assert model.example == [45, -46.2, 47.32] with pytest.raises(CalendarParseError): FakeModel.model_validate( {"example": [ParsedProperty(name="example", value="a")]} ) model = FakeModel(example=[1, -2.2, 3.5]) assert model.example == [1, -2.2, 3.5] allenporter-ical-fe8800b/tests/types/test_geo.py000066400000000000000000000012351510550726100220610ustar00rootroot00000000000000"""Library for GEO values.""" import pytest from ical.exceptions import CalendarParseError from ical.component import ComponentModel from ical.parsing.property import ParsedProperty from ical.types.geo import Geo def test_geo() -> None: """Test for geo fields.""" class TestModel(ComponentModel): """Model under test.""" geo: Geo model = TestModel.model_validate( {"geo": [ParsedProperty(name="geo", value="120.0;-30.1")]} ) assert model.geo.lat == 120.0 assert model.geo.lng == -30.1 with pytest.raises(CalendarParseError): TestModel.model_validate({"geo": [ParsedProperty(name="geo", value="10")]}) allenporter-ical-fe8800b/tests/types/test_integer.py000066400000000000000000000014701510550726100227450ustar00rootroot00000000000000"""Tests for INTEGER data types.""" import pytest from ical.exceptions import CalendarParseError from ical.component import ComponentModel from ical.parsing.property import ParsedProperty class FakeModel(ComponentModel): """Model under test.""" example: list[int] def test_integer() -> None: """Test for int fields.""" model = FakeModel.model_validate( { "example": [ ParsedProperty(name="example", value="45"), ParsedProperty(name="example", value="-46"), ParsedProperty(name="example", value="+47"), ] } ) assert model.example == [45, -46, 47] with pytest.raises(CalendarParseError): FakeModel.model_validate( {"example": [ParsedProperty(name="example", value="a")]} ) allenporter-ical-fe8800b/tests/types/test_period.py000066400000000000000000000061351510550726100225750ustar00rootroot00000000000000"""Tests for DURATION data types.""" import datetime from pydantic import field_serializer import pytest from ical.exceptions import CalendarParseError from ical.component import ComponentModel from ical.parsing.component import ParsedComponent from ical.parsing.property import ParsedProperty from ical.types import Period from ical.types.data_types import serialize_field class FakeModel(ComponentModel): """Model under test.""" example: Period serialize_fields = field_serializer("*")(serialize_field) # type: ignore[pydantic-field] def test_period() -> None: """Test for period fields.""" # Time period with end date model = FakeModel.model_validate( { "example": [ ParsedProperty( name="example", value="19970101T180000Z/19970102T070000Z" ) ] }, ) assert model.example.start == datetime.datetime( 1997, 1, 1, 18, 0, 0, tzinfo=datetime.timezone.utc ) assert model.example.end assert not model.example.duration assert model.example.end_value == datetime.datetime( 1997, 1, 2, 7, 0, 0, tzinfo=datetime.timezone.utc ) # Time period with duration model = FakeModel.model_validate( {"example": [ParsedProperty(name="example", value="19970101T180000Z/PT5H30M")]}, ) assert model.example.start == datetime.datetime( 1997, 1, 1, 18, 0, 0, tzinfo=datetime.timezone.utc ) assert not model.example.end assert model.example.duration assert model.example.end_value == datetime.datetime( 1997, 1, 1, 23, 30, 0, tzinfo=datetime.timezone.utc ) with pytest.raises(CalendarParseError): FakeModel.model_validate( {"example": [ParsedProperty(name="example", value="a")]} ) with pytest.raises(CalendarParseError): FakeModel.model_validate( {"example": [ParsedProperty(name="example", value="19970101T180000Z/a")]} ) with pytest.raises(CalendarParseError): FakeModel.model_validate( {"example": [ParsedProperty(name="example", value="a/19970102T070000Z")]} ) with pytest.raises(CalendarParseError): FakeModel.model_validate( {"example": [ParsedProperty(name="example", value="a/PT5H30M")]} ) def test_encode_period() -> None: """Test encoded period.""" model = FakeModel( example=Period( start=datetime.datetime(2022, 8, 7, 6, 0, 0), end=datetime.datetime(2022, 8, 7, 6, 30, 0), ) ) assert model.__encode_component_root__() == ParsedComponent( name="FakeModel", properties=[ ParsedProperty(name="example", value="20220807T060000/20220807T063000") ], ) model = FakeModel( example=Period( start=datetime.datetime(2022, 8, 7, 6, 0, 0), duration=datetime.timedelta(hours=5, minutes=30), ) ) assert model.__encode_component_root__() == ParsedComponent( name="FakeModel", properties=[ParsedProperty(name="example", value="20220807T060000/PT5H30M")], ) allenporter-ical-fe8800b/tests/types/test_priority.py000066400000000000000000000015211510550726100231660ustar00rootroot00000000000000"""Tests for PRIORITY types.""" import pytest from ical.exceptions import CalendarParseError from ical.component import ComponentModel from ical.parsing.property import ParsedProperty from ical.types import Priority class FakeModel(ComponentModel): """Model under test.""" pri: Priority def test_priority() -> None: """Test for priority fields.""" model = FakeModel.model_validate({"pri": [ParsedProperty(name="dt", value="1")]}) assert model.pri == 1 model = FakeModel.model_validate({"pri": [ParsedProperty(name="dt", value="9")]}) assert model.pri == 9 with pytest.raises(CalendarParseError): FakeModel.model_validate({"pri": [ParsedProperty(name="dt", value="-1")]}) with pytest.raises(CalendarParseError): FakeModel.model_validate({"pri": [ParsedProperty(name="dt", value="10")]}) allenporter-ical-fe8800b/tests/types/test_recur.py000066400000000000000000000760071510550726100224400ustar00rootroot00000000000000"""Tests for timeline related calendar eents.""" from __future__ import annotations import datetime import zoneinfo import pytest from ical.exceptions import CalendarParseError from ical.calendar import Calendar from ical.component import ComponentModel from ical.event import Event from ical.parsing.property import ParsedProperty, ParsedPropertyParameter from ical.timeline import Timeline from ical.todo import Todo from ical.types.recur import Frequency, Recur, RecurrenceId, Weekday, WeekdayValue def recur_timeline(event: Event) -> Timeline: """Create a timeline for the specified recurring event.""" calendar = Calendar() calendar.events.append(event) return calendar.timeline class FakeModel(ComponentModel): """Model under test.""" recurrence_id: RecurrenceId def test_recurrence_id_datetime() -> None: """Test a recurrence id datetime field.""" model = FakeModel.model_validate( { "recurrence_id": [ ParsedProperty(name="recurrence_id", value="20220724T120000") ] } ) assert model.recurrence_id == "20220724T120000" def test_recurrence_id_date() -> None: """Test a recurrence id date field.""" model = FakeModel.model_validate( {"recurrence_id": [ParsedProperty(name="recurrence_id", value="20220724")]} ) assert model.recurrence_id == "20220724" def test_recurrence_id_ignore_params() -> None: """Test property parameter values are ignored.""" model = FakeModel.model_validate( { "recurrence_id": [ ParsedProperty( name="recurrence_id", value="20220724T120000", params=[ ParsedPropertyParameter(name="RANGE", values=["THISANDFUTURE"]), ], ) ] } ) assert model.recurrence_id == "20220724T120000" def test_invalid_recurrence_id() -> None: """Test for a recurrence id that is not a valid DATE or DATE-TIME string.""" model = FakeModel.model_validate( {"recurrence_id": [ParsedProperty(name="recurrence_id", value="invalid")]} ) assert model.recurrence_id == "invalid" @pytest.mark.parametrize( "start,end,rrule,expected", [ ( datetime.date(2022, 8, 1), datetime.date(2022, 8, 2), Recur(freq=Frequency.DAILY, until=datetime.date(2022, 8, 4)), [ (datetime.date(2022, 8, 1), datetime.date(2022, 8, 2)), (datetime.date(2022, 8, 2), datetime.date(2022, 8, 3)), (datetime.date(2022, 8, 3), datetime.date(2022, 8, 4)), (datetime.date(2022, 8, 4), datetime.date(2022, 8, 5)), ], ), ( datetime.date(2022, 8, 1), datetime.date(2022, 8, 2), Recur(freq=Frequency.DAILY, until=datetime.date(2022, 8, 4), interval=2), [ (datetime.date(2022, 8, 1), datetime.date(2022, 8, 2)), (datetime.date(2022, 8, 3), datetime.date(2022, 8, 4)), ], ), ( datetime.date(2022, 8, 1), datetime.date(2022, 8, 2), Recur(freq=Frequency.DAILY, count=3), [ (datetime.date(2022, 8, 1), datetime.date(2022, 8, 2)), (datetime.date(2022, 8, 2), datetime.date(2022, 8, 3)), (datetime.date(2022, 8, 3), datetime.date(2022, 8, 4)), ], ), ( datetime.date(2022, 8, 1), datetime.date(2022, 8, 2), Recur(freq=Frequency.DAILY, interval=2, count=3), [ (datetime.date(2022, 8, 1), datetime.date(2022, 8, 2)), (datetime.date(2022, 8, 3), datetime.date(2022, 8, 4)), (datetime.date(2022, 8, 5), datetime.date(2022, 8, 6)), ], ), ( datetime.datetime(2022, 8, 1, 9, 30, 0), datetime.datetime(2022, 8, 1, 10, 0, 0), Recur(freq=Frequency.DAILY, until=datetime.datetime(2022, 8, 4, 9, 30, 0)), [ ( datetime.datetime(2022, 8, 1, 9, 30, 0), datetime.datetime(2022, 8, 1, 10, 0, 0), ), ( datetime.datetime(2022, 8, 2, 9, 30, 0), datetime.datetime(2022, 8, 2, 10, 0, 0), ), ( datetime.datetime(2022, 8, 3, 9, 30, 0), datetime.datetime(2022, 8, 3, 10, 0, 0), ), ( datetime.datetime(2022, 8, 4, 9, 30, 0), datetime.datetime(2022, 8, 4, 10, 0, 0), ), ], ), ], ) def test_day_iteration( start: datetime.datetime | datetime.date, end: datetime.datetime | datetime.date, rrule: Recur, expected: list[tuple[datetime.date, datetime.date]], ) -> None: """Test recurrence rules for day frequency.""" event = Event( summary="summary", start=start, end=end, rrule=rrule, ) timeline = recur_timeline(event) assert [(e.start, e.end) for e in timeline] == expected @pytest.mark.parametrize( "start,end,rrule,expected", [ ( datetime.date(2022, 8, 1), datetime.date(2022, 8, 2), Recur( freq=Frequency.WEEKLY, until=datetime.date(2022, 9, 6), by_weekday=[WeekdayValue(Weekday.MONDAY)], ), [ (datetime.date(2022, 8, 1), datetime.date(2022, 8, 2)), (datetime.date(2022, 8, 8), datetime.date(2022, 8, 9)), (datetime.date(2022, 8, 15), datetime.date(2022, 8, 16)), (datetime.date(2022, 8, 22), datetime.date(2022, 8, 23)), (datetime.date(2022, 8, 29), datetime.date(2022, 8, 30)), (datetime.date(2022, 9, 5), datetime.date(2022, 9, 6)), ], ), ( datetime.date(2022, 8, 1), datetime.date(2022, 8, 2), Recur( freq=Frequency.WEEKLY, until=datetime.date(2022, 9, 6), interval=2, by_weekday=[WeekdayValue(Weekday.MONDAY)], ), [ (datetime.date(2022, 8, 1), datetime.date(2022, 8, 2)), (datetime.date(2022, 8, 15), datetime.date(2022, 8, 16)), (datetime.date(2022, 8, 29), datetime.date(2022, 8, 30)), ], ), ( datetime.date(2022, 8, 1), datetime.date(2022, 8, 2), Recur( freq=Frequency.WEEKLY, count=3, by_weekday=[WeekdayValue(Weekday.MONDAY)], ), [ (datetime.date(2022, 8, 1), datetime.date(2022, 8, 2)), (datetime.date(2022, 8, 8), datetime.date(2022, 8, 9)), (datetime.date(2022, 8, 15), datetime.date(2022, 8, 16)), ], ), ( datetime.date(2022, 8, 1), datetime.date(2022, 8, 2), Recur( freq=Frequency.WEEKLY, interval=2, count=3, by_weekday=[WeekdayValue(Weekday.MONDAY)], ), [ (datetime.date(2022, 8, 1), datetime.date(2022, 8, 2)), (datetime.date(2022, 8, 15), datetime.date(2022, 8, 16)), (datetime.date(2022, 8, 29), datetime.date(2022, 8, 30)), ], ), ], ) def test_weekly_iteration( start: datetime.date | datetime.date, end: datetime.date | datetime.date, rrule: Recur, expected: list[tuple[datetime.date, datetime.date]], ) -> None: """Test recurrence rules for weekly frequency.""" event = Event( summary="summary", start=start, end=end, rrule=rrule, ) timeline = recur_timeline(event) assert [(e.start, e.end) for e in timeline] == expected @pytest.mark.parametrize( "start,end,rrule,expected", [ ( datetime.date(2022, 8, 1), datetime.date(2022, 8, 2), Recur( freq=Frequency.MONTHLY, until=datetime.date(2023, 1, 1), by_month_day=[1], ), [ (datetime.date(2022, 8, 1), datetime.date(2022, 8, 2)), (datetime.date(2022, 9, 1), datetime.date(2022, 9, 2)), (datetime.date(2022, 10, 1), datetime.date(2022, 10, 2)), (datetime.date(2022, 11, 1), datetime.date(2022, 11, 2)), (datetime.date(2022, 12, 1), datetime.date(2022, 12, 2)), (datetime.date(2023, 1, 1), datetime.date(2023, 1, 2)), ], ), ( datetime.date(2022, 8, 1), datetime.date(2022, 8, 2), Recur( freq=Frequency.MONTHLY, until=datetime.date(2023, 1, 1), interval=2, by_month_day=[1], ), [ (datetime.date(2022, 8, 1), datetime.date(2022, 8, 2)), (datetime.date(2022, 10, 1), datetime.date(2022, 10, 2)), (datetime.date(2022, 12, 1), datetime.date(2022, 12, 2)), ], ), ( datetime.date(2022, 8, 1), datetime.date(2022, 8, 2), Recur(freq=Frequency.MONTHLY, count=3, by_month_day=[1]), [ (datetime.date(2022, 8, 1), datetime.date(2022, 8, 2)), (datetime.date(2022, 9, 1), datetime.date(2022, 9, 2)), (datetime.date(2022, 10, 1), datetime.date(2022, 10, 2)), ], ), ( datetime.date(2022, 8, 1), datetime.date(2022, 8, 2), Recur( freq=Frequency.MONTHLY, interval=2, count=3, by_month_day=[1], ), [ (datetime.date(2022, 8, 1), datetime.date(2022, 8, 2)), (datetime.date(2022, 10, 1), datetime.date(2022, 10, 2)), (datetime.date(2022, 12, 1), datetime.date(2022, 12, 2)), ], ), ( datetime.datetime(2022, 8, 2, 9, 0, 0), datetime.datetime(2022, 8, 2, 9, 30, 0), Recur( freq=Frequency.MONTHLY, until=datetime.datetime(2023, 1, 1, 0, 0, 0), by_month_day=[2], ), [ ( datetime.datetime(2022, 8, 2, 9, 0, 0), datetime.datetime(2022, 8, 2, 9, 30, 0), ), ( datetime.datetime(2022, 9, 2, 9, 0, 0), datetime.datetime(2022, 9, 2, 9, 30, 0), ), ( datetime.datetime(2022, 10, 2, 9, 0, 0), datetime.datetime(2022, 10, 2, 9, 30, 0), ), ( datetime.datetime(2022, 11, 2, 9, 0, 0), datetime.datetime(2022, 11, 2, 9, 30, 0), ), ( datetime.datetime(2022, 12, 2, 9, 0, 0), datetime.datetime(2022, 12, 2, 9, 30, 0), ), ], ), ( datetime.date(2022, 8, 7), datetime.date(2022, 8, 8), Recur( freq=Frequency.MONTHLY, interval=2, count=3, by_weekday=[WeekdayValue(weekday=Weekday.SUNDAY, occurrence=1)], ), [ (datetime.date(2022, 8, 7), datetime.date(2022, 8, 8)), (datetime.date(2022, 10, 2), datetime.date(2022, 10, 3)), (datetime.date(2022, 12, 4), datetime.date(2022, 12, 5)), ], ), ( datetime.date(2022, 8, 7), datetime.date(2022, 8, 8), Recur( freq=Frequency.MONTHLY, count=3, by_weekday=[WeekdayValue(weekday=Weekday.SUNDAY, occurrence=-1)], ), [ (datetime.date(2022, 8, 28), datetime.date(2022, 8, 29)), (datetime.date(2022, 9, 25), datetime.date(2022, 9, 26)), (datetime.date(2022, 10, 30), datetime.date(2022, 10, 31)), ], ), ( datetime.date(2022, 8, 1), datetime.date(2022, 8, 2), Recur.from_rrule("FREQ=MONTHLY;INTERVAL=2;COUNT=3;BYMONTHDAY=1"), [ (datetime.date(2022, 8, 1), datetime.date(2022, 8, 2)), (datetime.date(2022, 10, 1), datetime.date(2022, 10, 2)), (datetime.date(2022, 12, 1), datetime.date(2022, 12, 2)), ], ), ], ) def test_monthly_iteration( start: datetime.date | datetime.date, end: datetime.date | datetime.date, rrule: Recur, expected: list[tuple[datetime.date, datetime.date]], ) -> None: """Test recurrency rules for monthly frequency.""" event = Event( summary="summary", start=start, end=end, rrule=rrule, ) timeline = recur_timeline(event) assert [(e.start, e.end) for e in timeline] == expected def test_recur_no_bound() -> None: """Test a recurrence rule with no end date.""" event = Event( summary="summary", start=datetime.date(2022, 8, 1), end=datetime.date(2022, 8, 2), rrule=Recur(freq=Frequency.DAILY, interval=2), ) timeline = recur_timeline(event) def on_date(day: datetime.date) -> list[datetime.date]: return [e.start for e in timeline.on_date(day)] assert on_date(datetime.date(2022, 8, 1)) == [datetime.date(2022, 8, 1)] assert on_date(datetime.date(2022, 8, 2)) == [] assert on_date(datetime.date(2022, 8, 3)) == [datetime.date(2022, 8, 3)] assert on_date(datetime.date(2025, 1, 1)) == [datetime.date(2025, 1, 1)] assert on_date(datetime.date(2025, 1, 2)) == [] assert on_date(datetime.date(2025, 1, 3)) == [datetime.date(2025, 1, 3)] assert on_date(datetime.date(2035, 9, 1)) == [] assert on_date(datetime.date(2035, 9, 2)) == [datetime.date(2035, 9, 2)] assert on_date(datetime.date(2035, 9, 3)) == [] def test_merged_recur_event_timeline() -> None: """Test merged recurring and events timeline.""" calendar = Calendar() calendar.events.extend( [ Event( summary="Morning exercise", start=datetime.datetime(2022, 8, 2, 6, 0, 0), end=datetime.datetime(2022, 8, 2, 7, 0, 0), rrule=Recur(freq=Frequency.DAILY), ), Event( summary="Meeting", start=datetime.datetime(2022, 8, 2, 10, 0, 0), end=datetime.datetime(2022, 8, 2, 10, 30, 0), ), Event( summary="Trash day", start=datetime.date(2022, 8, 3), end=datetime.date(2022, 8, 4), rrule=Recur( freq=Frequency.WEEKLY, by_weekday=[WeekdayValue(Weekday.WEDNESDAY)] ), ), Event( summary="Appointment", start=datetime.datetime(2022, 8, 5, 8, 0, 0), end=datetime.datetime(2022, 8, 5, 8, 30, 0), ), Event( summary="Pay day", start=datetime.date(2022, 8, 15), end=datetime.date(2022, 8, 16), rrule=Recur(freq=Frequency.MONTHLY, by_month_day=[15]), ), ] ) events = calendar.timeline.included( datetime.date(2022, 8, 1), datetime.date(2022, 9, 2), ) assert [(event.start, event.summary) for event in events] == [ (datetime.datetime(2022, 8, 2, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 2, 10, 0, 0), "Meeting"), (datetime.date(2022, 8, 3), "Trash day"), (datetime.datetime(2022, 8, 3, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 4, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 5, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 5, 8, 0, 0), "Appointment"), (datetime.datetime(2022, 8, 6, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 7, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 8, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 9, 6, 0, 0), "Morning exercise"), (datetime.date(2022, 8, 10), "Trash day"), (datetime.datetime(2022, 8, 10, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 11, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 12, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 13, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 14, 6, 0, 0), "Morning exercise"), (datetime.date(2022, 8, 15), "Pay day"), (datetime.datetime(2022, 8, 15, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 16, 6, 0, 0), "Morning exercise"), (datetime.date(2022, 8, 17), "Trash day"), (datetime.datetime(2022, 8, 17, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 18, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 19, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 20, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 21, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 22, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 23, 6, 0, 0), "Morning exercise"), (datetime.date(2022, 8, 24), "Trash day"), (datetime.datetime(2022, 8, 24, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 25, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 26, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 27, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 28, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 29, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 30, 6, 0, 0), "Morning exercise"), (datetime.date(2022, 8, 31), "Trash day"), (datetime.datetime(2022, 8, 31, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 9, 1, 6, 0, 0), "Morning exercise"), ] def test_exclude_date() -> None: """Test recurrence rule with date exclusions.""" calendar = Calendar() calendar.events.extend( [ Event( summary="Morning exercise", start=datetime.datetime(2022, 8, 2, 6, 0, 0), end=datetime.datetime(2022, 8, 2, 7, 0, 0), rrule=Recur( freq=Frequency.DAILY, until=datetime.datetime(2022, 8, 10, 6, 0, 0) ), exdate=[ datetime.datetime(2022, 8, 3, 6, 0, 0), datetime.datetime(2022, 8, 4, 6, 0, 0), datetime.datetime(2022, 8, 6, 6, 0, 0), datetime.datetime(2022, 8, 7, 6, 0, 0), ], ), ] ) events = list(calendar.timeline) assert [(event.start, event.summary) for event in events] == [ (datetime.datetime(2022, 8, 2, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 5, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 8, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 9, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 10, 6, 0, 0), "Morning exercise"), ] def test_rdate() -> None: """Test recurrence rule with specific dates and exclusions.""" calendar = Calendar() calendar.events.extend( [ Event( summary="Morning exercise", start=datetime.datetime(2022, 8, 2, 6, 0, 0), end=datetime.datetime(2022, 8, 2, 7, 0, 0), rdate=[ datetime.datetime(2022, 8, 2, 6, 0, 0), datetime.datetime(2022, 8, 3, 6, 0, 0), datetime.datetime(2022, 8, 4, 6, 0, 0), datetime.datetime(2022, 8, 5, 6, 0, 0), datetime.datetime(2022, 8, 6, 6, 0, 0), datetime.datetime(2022, 8, 7, 6, 0, 0), datetime.datetime(2022, 8, 8, 6, 0, 0), datetime.datetime(2022, 8, 9, 6, 0, 0), datetime.datetime(2022, 8, 10, 6, 0, 0), ], exdate=[ datetime.datetime(2022, 8, 3, 6, 0, 0), datetime.datetime(2022, 8, 4, 6, 0, 0), datetime.datetime(2022, 8, 6, 6, 0, 0), datetime.datetime(2022, 8, 7, 6, 0, 0), ], ), ] ) events = list(calendar.timeline) assert [(event.start, event.summary) for event in events] == [ (datetime.datetime(2022, 8, 2, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 5, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 8, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 9, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 10, 6, 0, 0), "Morning exercise"), ] def test_year_iteration() -> None: """Test iteration with a yearly frequency.""" calendar = Calendar() calendar.events.extend( [ Event( summary="Bi-annual meeting", start=datetime.datetime(2022, 1, 2, 6, 0, 0), end=datetime.datetime(2022, 1, 2, 7, 0, 0), rrule=Recur(freq=Frequency.YEARLY, by_month=[1, 6], count=4), ), ] ) events = list(calendar.timeline) assert [(event.start, event.summary) for event in events] == [ (datetime.datetime(2022, 1, 2, 6, 0, 0), "Bi-annual meeting"), (datetime.datetime(2022, 6, 2, 6, 0, 0), "Bi-annual meeting"), (datetime.datetime(2023, 1, 2, 6, 0, 0), "Bi-annual meeting"), (datetime.datetime(2023, 6, 2, 6, 0, 0), "Bi-annual meeting"), ] @pytest.mark.parametrize( ("tzinfo", "until_tzinfo"), [ (None, None), (zoneinfo.ZoneInfo("America/New_York"), datetime.timezone.utc), ], ) def test_until_time_valid( tzinfo: zoneinfo.ZoneInfo | None, until_tzinfo: zoneinfo.ZoneInfo | None ) -> None: """Test success cases where until has a valid date or time compared to dtstart.""" Event( summary="Bi-annual meeting", start=datetime.datetime(2022, 1, 2, 6, 0, 0, tzinfo=tzinfo), end=datetime.datetime(2022, 1, 2, 7, 0, 0, tzinfo=tzinfo), rrule=Recur( freq=Frequency.DAILY, until=datetime.datetime(2022, 8, 4, 6, 0, 0, tzinfo=until_tzinfo), ), ) def test_until_time_mismatch() -> None: """Test failure case where until has a different timezone than start.""" with pytest.raises( CalendarParseError, match="DTSTART was DATE-TIME but UNTIL was DATE", ): Event( summary="Bi-annual meeting", start=datetime.datetime(2022, 1, 2, 6, 0, 0), end=datetime.datetime(2022, 1, 2, 7, 0, 0), rrule=Recur( freq=Frequency.DAILY, until=datetime.date(2022, 8, 4), ), ) with pytest.raises( CalendarParseError, match="DTSTART is date local but UNTIL was not" ): Event( summary="Bi-annual meeting", start=datetime.datetime(2022, 1, 2, 6, 0, 0), end=datetime.datetime(2022, 1, 2, 7, 0, 0), rrule=Recur( freq=Frequency.DAILY, until=datetime.datetime( 2022, 8, 4, 1, 0, 0, tzinfo=datetime.timezone.utc ), ), ) with pytest.raises( CalendarParseError, match="DTSTART had UTC or local and UNTIL must be UTC" ): Event( summary="Bi-annual meeting", start=datetime.datetime(2022, 1, 2, 6, 0, 0, tzinfo=datetime.timezone.utc), end=datetime.datetime(2022, 1, 2, 7, 0, 0), rrule=Recur( freq=Frequency.DAILY, until=datetime.datetime( 2022, 8, 4, 1, 0, 0, tzinfo=zoneinfo.ZoneInfo("America/New_York") ), ), ) with pytest.raises( CalendarParseError, match="DTSTART had UTC or local and UNTIL must be UTC" ): Event( summary="Bi-annual meeting", start=datetime.datetime( 2022, 1, 2, 6, 0, 0, tzinfo=zoneinfo.ZoneInfo("America/New_York") ), end=datetime.datetime(2022, 1, 2, 7, 0, 0), rrule=Recur( freq=Frequency.DAILY, until=datetime.datetime( 2022, 8, 4, 1, 0, 0, tzinfo=zoneinfo.ZoneInfo("America/New_York") ), ), ) @pytest.mark.parametrize( "recur", [ Recur(freq=Frequency.DAILY, interval=2), Recur.from_rrule("FREQ=DAILY;INTERVAL=2"), ], ) def test_recur_as_string(recur: Recur) -> None: """Test converting a recurrence rule back to a string.""" event = Event( summary="summary", start=datetime.date(2022, 8, 1), end=datetime.date(2022, 8, 2), rrule=recur, ) assert event.rrule assert event.rrule.as_rrule_str() == "FREQ=DAILY;INTERVAL=2" @pytest.mark.parametrize( "recur", [ Recur(freq=Frequency.DAILY, interval=2), Recur.from_rrule("FREQ=DAILY;INTERVAL=2"), ], ) def test_todo_recur_as_string(recur: Recur) -> None: """Test converting a recurrence rule back to a string.""" event = Todo( summary="summary", due=datetime.date(2022, 8, 1), rrule=recur, ) assert event.rrule assert event.rrule.as_rrule_str() == "FREQ=DAILY;INTERVAL=2" @pytest.mark.parametrize( "recur", [ Recur(freq=Frequency.DAILY, until=datetime.datetime(2022, 8, 10, 6, 0, 0)), Recur.from_rrule("FREQ=DAILY;UNTIL=20220810T060000"), ], ) def test_recur_until_as_string(recur: Recur) -> None: """Test converting a recurrence rule with a date into a string.""" event = Event( summary="summary", start=datetime.datetime(2022, 8, 1, 6, 0, 0), end=datetime.datetime(2022, 8, 2, 6, 30, 0), rrule=recur, ) assert event.rrule assert event.rrule.as_rrule_str() == "FREQ=DAILY;UNTIL=20220810T060000" @pytest.mark.parametrize( "recur", [ Recur(freq=Frequency.WEEKLY, by_weekday=[WeekdayValue(Weekday.TUESDAY)]), Recur.from_rrule("FREQ=WEEKLY;BYDAY=TU"), ], ) def test_recur_by_weekday_as_string(recur: Recur) -> None: """Test converting a recurrence rule with a weekday repeat into a string.""" event = Event( summary="summary", start=datetime.date(2022, 9, 6), end=datetime.date(2022, 9, 7), rrule=recur, ) assert event.rrule assert event.rrule.as_rrule_str() == "FREQ=WEEKLY;BYDAY=TU" @pytest.mark.parametrize( "recur", [ Recur( freq=Frequency.MONTHLY, until=datetime.date(2023, 1, 1), by_month_day=[1] ), Recur.from_rrule("FREQ=MONTHLY;UNTIL=20230101;BYMONTHDAY=1"), ], ) def test_recur_by_monthday_as_string(recur: Recur) -> None: """Test converting a recurrence rule with a weekday reepat into a string.""" event = Event( summary="summary", start=datetime.date(2022, 9, 6), end=datetime.date(2022, 9, 7), rrule=recur, ) assert event.rrule assert event.rrule.as_rrule_str() == "FREQ=MONTHLY;UNTIL=20230101;BYMONTHDAY=1" @pytest.mark.parametrize( "recur", [ Recur( freq=Frequency.MONTHLY, by_weekday=[WeekdayValue(weekday=Weekday.TUESDAY, occurrence=-1)], ), Recur.from_rrule("FREQ=MONTHLY;BYDAY=-1TU"), ], ) def test_recur_by_last_day_as_string(recur: Recur) -> None: """Test converting a recurrence rule with a weekday repeat into a string.""" event = Event( summary="summary", start=datetime.date(2022, 9, 6), end=datetime.date(2022, 9, 7), rrule=recur, ) assert event.rrule assert event.rrule.as_rrule_str() == "FREQ=MONTHLY;BYDAY=-1TU" def test_rdate_all_day() -> None: """Test how all day events are handled with RDATE.""" calendar = Calendar() calendar.events.extend( [ Event( summary="Monthly Event", start=datetime.date(2022, 8, 2), end=datetime.date(2022, 8, 2), rdate=[ datetime.date(2022, 8, 3), datetime.date(2022, 8, 4), datetime.date(2022, 10, 3), ], rrule=Recur.from_rrule("FREQ=MONTHLY;COUNT=3"), ), ] ) events = list(calendar.timeline) assert [(event.start, event.summary) for event in events] == [ (datetime.date(2022, 8, 2), "Monthly Event"), (datetime.date(2022, 8, 3), "Monthly Event"), (datetime.date(2022, 8, 4), "Monthly Event"), (datetime.date(2022, 9, 2), "Monthly Event"), (datetime.date(2022, 10, 2), "Monthly Event"), (datetime.date(2022, 10, 3), "Monthly Event"), ] def test_rrule_exdate_mismatch() -> None: """Test a recurrence rule with mixed datetime and exdate values.""" calendar = Calendar() calendar.events.extend( [ Event( summary="Morning exercise", start=datetime.datetime(2022, 8, 2, 6, 0, 0), end=datetime.datetime(2022, 8, 2, 7, 0, 0), rrule=Recur( freq=Frequency.WEEKLY, until=datetime.datetime(2022, 8, 10, 6, 0, 0) ), exdate=[ datetime.date(2022, 8, 3), datetime.date(2022, 8, 4), datetime.date(2022, 8, 5), ], ), ] ) events = list(calendar.timeline) assert [(event.start, event.summary) for event in events] == [ (datetime.datetime(2022, 8, 2, 6, 0, 0), "Morning exercise"), (datetime.datetime(2022, 8, 9, 6, 0, 0), "Morning exercise"), ] # BYSETPOS def test_bysetpos() -> None: """Test how all day events are handled with RDATE.""" calendar = Calendar() calendar.events.extend( [ Event( summary="Monthly Event", start=datetime.date(2023, 1, 31), end=datetime.date(2023, 2, 1), # Last work day of the month rrule=Recur.from_rrule( "FREQ=MONTHLY;BYSETPOS=-1;BYDAY=MO,TU,WE,TH,FR;COUNT=5" ), ), ] ) events = list(calendar.timeline) assert [(event.start, event.summary) for event in events] == [ (datetime.date(2023, 1, 31), "Monthly Event"), (datetime.date(2023, 2, 28), "Monthly Event"), (datetime.date(2023, 3, 31), "Monthly Event"), (datetime.date(2023, 4, 28), "Monthly Event"), (datetime.date(2023, 5, 31), "Monthly Event"), ] allenporter-ical-fe8800b/tests/types/test_related.py000066400000000000000000000076221510550726100227350ustar00rootroot00000000000000"""Tests for RELATED-TO data types.""" from pydantic import field_serializer import pytest from ical.exceptions import CalendarParseError from ical.component import ComponentModel from ical.parsing.component import ParsedComponent from ical.parsing.property import ParsedProperty, ParsedPropertyParameter from ical.types import RelatedTo, RelationshipType from ical.types.data_types import serialize_field class FakeModel(ComponentModel): """Model under test.""" example: RelatedTo serialize_fields = field_serializer("*")(serialize_field) # type: ignore[pydantic-field] def test_default_reltype() -> None: """Test for no explicit reltype specified.""" model = FakeModel.model_validate( { "example": [ ParsedProperty( name="example", value="example-uid@example.com", params=[ParsedPropertyParameter(name="RELTYPE", values=["PARENT"])], ) ] }, ) assert model.example assert model.example.uid == "example-uid@example.com" assert model.example.reltype == "PARENT" @pytest.mark.parametrize( "reltype", [ ("PARENT"), ("CHILD"), ("SIBBLING"), ], ) def test_reltype(reltype: str) -> None: """Test for no explicit reltype specified.""" model = FakeModel.model_validate( { "example": [ ParsedProperty( name="example", value="example-uid@example.com", params=[ParsedPropertyParameter(name="reltype", values=[reltype])], ) ] }, ) assert model.example assert model.example.uid == "example-uid@example.com" assert model.example.reltype == reltype def test_invalid_reltype() -> None: with pytest.raises(CalendarParseError): model = FakeModel.model_validate( { "example": [ ParsedProperty( name="example", value="example-uid@example.com", params=[ ParsedPropertyParameter( name="reltype", values=["invalid-reltype"] ) ], ) ] }, ) def test_too_many_reltype_values() -> None: with pytest.raises(CalendarParseError): FakeModel.model_validate( { "example": [ ParsedProperty( name="example", value="example-uid@example.com", params=[ ParsedPropertyParameter( name="reltype", values=["PARENT", "SIBBLING"] ) ], ) ] }, ) def test_encode_default_reltype() -> None: """Test encoded period.""" model = FakeModel(example=RelatedTo(uid="example-uid@example.com")) assert model.__encode_component_root__() == ParsedComponent( name="FakeModel", properties=[ ParsedProperty( name="example", value="example-uid@example.com", params=[ParsedPropertyParameter(name="RELTYPE", values=["PARENT"])], ), ], ) def test_encode_reltype() -> None: """Test encoded period.""" model = FakeModel( example=RelatedTo(uid="example-uid@example.com", reltype=RelationshipType.CHILD) ) assert model.__encode_component_root__() == ParsedComponent( name="FakeModel", properties=[ ParsedProperty( name="example", value="example-uid@example.com", params=[ParsedPropertyParameter(name="RELTYPE", values=["CHILD"])], ), ], ) allenporter-ical-fe8800b/tests/types/test_text.py000066400000000000000000000023741510550726100223000ustar00rootroot00000000000000"""Tests for property values.""" from ical.component import ComponentModel from ical.parsing.component import ParsedComponent from ical.parsing.property import ParsedProperty class Model(ComponentModel): """Model with a Text value.""" text_value: str def test_text() -> None: """Test for a text property value.""" component = ParsedComponent(name="text-model") component.properties.append( ParsedProperty( name="text_value", value="Project XYZ Final Review\\nConference Room - 3B\\nCome Prepared.", ) ) model = Model.model_validate(component.as_dict()) assert model == Model( text_value="\n".join( ["Project XYZ Final Review", "Conference Room - 3B", "Come Prepared."] ) ) assert model.__encode_component_root__() == ParsedComponent( name="Model", properties=[ ParsedProperty( name="text_value", value="Project XYZ Final Review\\nConference Room - 3B\\nCome Prepared.", ) ], ) def test_text_from_obj() -> None: """Test text when creating from an object.""" model = Model.model_validate({"text_value": "some-value"}) assert model == Model(text_value="some-value") allenporter-ical-fe8800b/tests/types/test_utc_offset.py000066400000000000000000000036341510550726100234550ustar00rootroot00000000000000"""Tests for UTC-OFFSET data types.""" import datetime import pytest from ical.exceptions import CalendarParseError from ical.component import ComponentModel from ical.parsing.property import ParsedProperty from ical.types import UtcOffset class FakeModel(ComponentModel): """Model under test.""" example: UtcOffset def test_utc_offset() -> None: """Test for UTC offset fields.""" model = FakeModel.model_validate( {"example": [ParsedProperty(name="example", value="-0400")]} ) assert model.example.offset == datetime.timedelta(hours=-4) model = FakeModel.model_validate( {"example": [ParsedProperty(name="example", value="0500")]} ) assert model.example.offset == datetime.timedelta(hours=5) model = FakeModel(example=UtcOffset(offset=datetime.timedelta(hours=5))) assert model.example.offset == datetime.timedelta(hours=5) with pytest.raises(CalendarParseError, match=r".*match UTC-OFFSET pattern.*"): FakeModel.model_validate( {"example": [ParsedProperty(name="example", value="abcdef")]}, ) def test_optional_seconds() -> None: """Test for UTC offset fields with optional seconds.""" model = FakeModel.model_validate( {"example": [ParsedProperty(name="example", value="+0019")]} ) assert model.example.offset == datetime.timedelta(minutes=19) model = FakeModel.model_validate( {"example": [ParsedProperty(name="example", value="+001932")]} ) assert model.example.offset == datetime.timedelta(minutes=19, seconds=32) model = FakeModel.model_validate( {"example": [ParsedProperty(name="example", value="-0019")]} ) assert model.example.offset == datetime.timedelta(minutes=-19) model = FakeModel.model_validate( {"example": [ParsedProperty(name="example", value="-001932")]} ) assert model.example.offset == datetime.timedelta(minutes=-19, seconds=-32) allenporter-ical-fe8800b/tests/tzif/000077500000000000000000000000001510550726100175055ustar00rootroot00000000000000allenporter-ical-fe8800b/tests/tzif/__snapshots__/000077500000000000000000000000001510550726100223235ustar00rootroot00000000000000allenporter-ical-fe8800b/tests/tzif/__snapshots__/test_rfc8536.ambr000066400000000000000000000061431510550726100253310ustar00rootroot00000000000000# serializer version: 1 # name: test_parse[rfc8536-v1] dict({ 'leap_seconds': list([ list([ 78796800, 1, ]), list([ 94694401, 2, ]), list([ 126230402, 3, ]), list([ 157766403, 4, ]), list([ 189302404, 5, ]), list([ 220924805, 6, ]), list([ 252460806, 7, ]), list([ 283996807, 8, ]), list([ 315532808, 9, ]), list([ 362793609, 10, ]), list([ 394329610, 11, ]), list([ 425865611, 12, ]), list([ 489024012, 13, ]), list([ 567993613, 14, ]), list([ 631152014, 15, ]), list([ 662688015, 16, ]), list([ 709948816, 17, ]), list([ 741484817, 18, ]), list([ 773020818, 19, ]), list([ 820454419, 20, ]), list([ 867715220, 21, ]), list([ 915148821, 22, ]), list([ 1136073622, 23, ]), list([ 1230768023, 24, ]), list([ 1341100824, 25, ]), list([ 1435708825, 26, ]), list([ 1483228826, 27, ]), ]), }) # --- # name: test_parse[rfc8536-v2] dict({ 'rule': dict({ 'dst': None, 'dst_end': None, 'dst_start': None, 'std': dict({ 'name': 'HST', 'offset': '-PT10H', }), }), 'transitions': list([ dict({ 'designation': 'HST', 'dst': False, 'isstdcnt': False, 'isutccnt': False, 'transition_time': -2334101314, 'utoff': -37800, }), dict({ 'designation': 'HDT', 'dst': True, 'isstdcnt': False, 'isutccnt': False, 'transition_time': -1157283000, 'utoff': -34200, }), dict({ 'designation': 'HST', 'dst': False, 'isstdcnt': False, 'isutccnt': False, 'transition_time': -1155436200, 'utoff': -37800, }), dict({ 'designation': 'HWT', 'dst': True, 'isstdcnt': False, 'isutccnt': False, 'transition_time': -880198200, 'utoff': -34200, }), dict({ 'designation': 'HPT', 'dst': True, 'isstdcnt': True, 'isutccnt': True, 'transition_time': -769395600, 'utoff': -34200, }), dict({ 'designation': 'HST', 'dst': False, 'isstdcnt': False, 'isutccnt': False, 'transition_time': -765376200, 'utoff': -37800, }), dict({ 'designation': 'HST', 'dst': False, 'isstdcnt': False, 'isutccnt': False, 'transition_time': -712150200, 'utoff': -36000, }), ]), }) # --- allenporter-ical-fe8800b/tests/tzif/test_rfc8536.py000066400000000000000000000023351510550726100222210ustar00rootroot00000000000000"""Tests for rfc8536 examples.""" import io import json import pathlib import re import pytest from syrupy import SnapshotAssertion from ical.tzif.tzif import read_tzif RFC_LINE = re.compile(r"\|(?:[0-9]|\s)+\| (.*?)\s+\| .+ \| .+ \|") TESTDATA_PATH = pathlib.Path("tests/tzif/testdata/") TESTDATA_FILES = list(TESTDATA_PATH.glob("*.txt")) TESTDATA_IDS = [x.stem for x in TESTDATA_FILES] def rfc_to_binary(rfc_text: str) -> bytes: """Convert the RFC example text to a binary blob.""" buf = io.BytesIO() for line in rfc_text.split("\n"): match = RFC_LINE.match(line) if not match: continue if not (payload := match.group(1)): continue buf.write(bytearray.fromhex(payload.replace(" ", ""))) return buf.getvalue() @pytest.mark.parametrize("filename", TESTDATA_FILES, ids=TESTDATA_IDS) def test_parse( filename: pathlib.Path, snapshot: SnapshotAssertion, json_encoder: json.JSONEncoder ) -> None: """Test that reads RFC examples from golden files.""" content = rfc_to_binary(filename.read_text()) print(content) result = read_tzif(content) obj_data = json.loads(json.dumps(result, default=json_encoder.default)) assert obj_data == snapshot allenporter-ical-fe8800b/tests/tzif/test_timezoneinfo.py000066400000000000000000000142311510550726100236250ustar00rootroot00000000000000"""Tests for the tzif library.""" import datetime from unittest.mock import patch import zoneinfo import pytest from ical.tzif import timezoneinfo, tz_rule IGNORED_TIMEZONES = { "Asia/Hanoi", # Not in tzdata } def test_invalid_zoneinfo() -> None: """Verify exception handling for an invalid timezone.""" with pytest.raises(timezoneinfo.TimezoneInfoError, match="Unable to find timezone"): timezoneinfo.read("invalid") @pytest.mark.parametrize( "key,dtstarts,expected_tzname,expected_offset", [ ( "America/Los_Angeles", [ datetime.datetime(2021, 3, 14, 1, 59, 0), datetime.datetime(2021, 11, 7, 2, 0, 0), datetime.datetime(2022, 3, 13, 1, 59, 0), datetime.datetime(2022, 11, 6, 2, 0, 0), datetime.datetime(2023, 3, 12, 1, 59, 0), datetime.datetime(2023, 11, 5, 2, 0, 0), ], "PST", datetime.timedelta(hours=-8), ), ( "America/Los_Angeles", [ datetime.datetime(2021, 3, 14, 2, 0, 0), datetime.datetime(2021, 11, 7, 1, 59, 0), datetime.datetime(2022, 3, 13, 2, 0, 0), datetime.datetime(2022, 11, 6, 1, 59, 0), datetime.datetime(2023, 3, 12, 2, 0, 0), datetime.datetime(2023, 11, 5, 1, 59, 0), ], "PDT", datetime.timedelta(hours=-7), ), ( "Europe/Warsaw", [ datetime.datetime(2021, 3, 28, 1, 59, 0), datetime.datetime(2021, 10, 31, 3, 0, 0), datetime.datetime(2022, 3, 27, 1, 59, 0), datetime.datetime(2022, 10, 30, 3, 0, 0), datetime.datetime(2023, 3, 26, 1, 59, 0), datetime.datetime(2023, 10, 29, 3, 0, 0), datetime.datetime(2024, 3, 31, 1, 59, 0), datetime.datetime(2024, 10, 27, 3, 0, 0), ], "CET", datetime.timedelta(hours=1), ), ( "Europe/Warsaw", [ datetime.datetime(2021, 3, 28, 2, 0, 0), datetime.datetime(2021, 10, 31, 2, 59, 0), datetime.datetime(2022, 3, 27, 2, 0, 0), datetime.datetime(2022, 10, 30, 2, 59, 0), datetime.datetime(2023, 3, 26, 2, 0, 0), datetime.datetime(2023, 10, 29, 2, 59, 0), datetime.datetime(2024, 3, 31, 2, 0, 0), datetime.datetime(2024, 10, 27, 2, 59, 0), ], "CEST", datetime.timedelta(hours=2), ), ( "Asia/Tokyo", [ # Fixed offset anytime of year datetime.datetime(2021, 1, 1, 0, 0, 0), datetime.datetime(2022, 3, 1, 0, 0, 0), datetime.datetime(2022, 6, 1, 0, 0, 0), datetime.datetime(2023, 7, 1, 0, 0, 0), datetime.datetime(2023, 12, 1, 0, 0, 0), ], "JST", datetime.timedelta(hours=9), ), ( "America/St_Thomas", [ # Fixed offset anytime of year datetime.datetime(2021, 1, 1, 0, 0, 0), datetime.datetime(2022, 3, 1, 0, 0, 0), datetime.datetime(2022, 6, 1, 0, 0, 0), datetime.datetime(2023, 7, 1, 0, 0, 0), datetime.datetime(2023, 12, 1, 0, 0, 0), ], "AST", datetime.timedelta(hours=-4), ), ], ) def test_tzinfo( key: str, dtstarts: list[datetime.datetime], expected_tzname: str, expected_offset: datetime.timedelta, ) -> None: """Test TzInfo implementation for known date/times.""" tz_info = timezoneinfo.read_tzinfo(key) for dtstart in dtstarts: value = dtstart.replace(tzinfo=tz_info) assert tz_info.tzname(value) == expected_tzname, f"For {dtstart}" assert tz_info.utcoffset(value) == expected_offset, f"For {dtstart}" assert not tz_info.utcoffset(None) assert not tz_info.tzname(None) assert not tz_info.dst(None) def test_rrule_str() -> None: """Test rule implementations for std and dst.""" result = timezoneinfo.read("America/New_York") assert result.rule assert result.rule.dst_start assert isinstance(result.rule.dst_start, tz_rule.RuleDate) assert result.rule.dst_start.rrule_str == "FREQ=YEARLY;BYMONTH=3;BYDAY=2SU" assert result.rule.dst_end assert isinstance(result.rule.dst_end, tz_rule.RuleDate) assert result.rule.dst_end.rrule_str == "FREQ=YEARLY;BYMONTH=11;BYDAY=1SU" @pytest.mark.parametrize("key", zoneinfo.available_timezones()) def test_all_zoneinfo(key: str) -> None: """Verify that all available timezones in the system have valid tzdata.""" if key.startswith("System") or key == "localtime" or key in IGNORED_TIMEZONES: return result = timezoneinfo.read(key) assert result.rule if result.rule.dst_start: assert result.rule.dst_end assert isinstance(result.rule.dst_start, tz_rule.RuleDate) assert isinstance(result.rule.dst_end, tz_rule.RuleDate) # Verify a rule can be constructed assert next(iter(result.rule.dst_start.as_rrule())) assert next(iter(result.rule.dst_end.as_rrule())) else: # Fixed offset assert result.rule assert result.rule.std assert result.rule.std.name assert not result.rule.dst # Verify there is a paresable tz rule timezoneinfo.TzInfo.from_timezoneinfo(result) def test_read_tzinfo_value_error() -> None: """Test TzInfo implementation for known date/times.""" with ( patch("ical.tzif.timezoneinfo._read_tzdata_timezones", return_value=["X/Y"]), patch("ical.tzif.timezoneinfo._find_tzfile"), patch( "ical.tzif.timezoneinfo.read_tzif", side_effect=ValueError("zoneinfo file did not contain magic header"), ), pytest.raises( timezoneinfo.TimezoneInfoError, match="Unable to load tzdata file: X/Y" ), ): timezoneinfo.read_tzinfo("X/Y") allenporter-ical-fe8800b/tests/tzif/test_tz_rule.py000066400000000000000000000175301510550726100226100ustar00rootroot00000000000000"""Tests for the tzif library.""" import datetime from typing import cast, Any import pytest from ical.tzif import tz_rule TEST_DATETIME = datetime.datetime(2022, 1, 1) def expand_rule(test_rule: tz_rule.RuleDate) -> datetime.datetime: """Test method to expand a rule to a single value.""" return cast(datetime.datetime, next(iter(test_rule.as_rrule(TEST_DATETIME)))) def test_standard() -> None: """Test standard time with no daylight savings time.""" rule = tz_rule.parse_tz_rule("EST5") assert rule.std.name == "EST" assert rule.std.offset == datetime.timedelta(hours=-5) assert rule.dst is None assert rule.dst_start is None assert rule.dst_end is None def test_standard_plus_offset() -> None: """Test standard time with an offset with an explicit plus.""" rule = tz_rule.parse_tz_rule("EST+5") assert rule.std.name == "EST" assert rule.std.offset == datetime.timedelta(hours=-5) assert rule.dst is None assert rule.dst_start is None assert rule.dst_end is None def test_hours_minutes_offset() -> None: """Test standard time offset with hours and minutes.""" rule = tz_rule.parse_tz_rule("EX05:30") assert rule.std.name == "EX" assert rule.std.offset == datetime.timedelta(hours=-5, minutes=-30) assert rule.dst is None assert rule.dst_start is None assert rule.dst_end is None def test_hours_minutes_seconds_offset() -> None: """Test standard time offset with hours, minutes, and seconds.""" rule = tz_rule.parse_tz_rule("EX05:30:20") assert rule.std.name == "EX" assert rule.std.offset == datetime.timedelta(hours=-5, minutes=-30, seconds=-20) assert rule.dst is None assert rule.dst_start is None assert rule.dst_end is None def test_standard_minus_offset() -> None: """Test standard time offset with a negative offset.""" rule = tz_rule.parse_tz_rule("JST-9") assert rule.std.name == "JST" assert rule.std.offset == datetime.timedelta(hours=9) assert rule.dst is None assert rule.dst_start is None assert rule.dst_end is None def test_dst_implicit_offset() -> None: """Test daylight savings time with an implicit offset.""" rule = tz_rule.parse_tz_rule("EST5EDT") assert rule.std.name == "EST" assert rule.std.offset == datetime.timedelta(hours=-5) assert rule.dst assert rule.dst.name == "EDT" assert rule.dst.offset == datetime.timedelta(hours=-4) assert rule.dst_start is None assert rule.dst_end is None def test_standard_dst_implied_offset() -> None: """Test standard time with an offset with an explicit plus.""" rule = tz_rule.parse_tz_rule("PST8PDT") assert rule.std.name == "PST" assert rule.std.offset == datetime.timedelta(hours=-8) assert rule.dst assert rule.dst.name == "PDT" assert rule.dst.offset == datetime.timedelta(hours=-7) assert rule.dst_start is None assert rule.dst_end is None def test_dst_explicit_offset() -> None: """Test standard time with no daylight savings time.""" rule = tz_rule.parse_tz_rule("EST5EDT4") assert rule.std.name == "EST" assert rule.std.offset == datetime.timedelta(hours=-5) assert rule.dst assert rule.dst.name == "EDT" assert rule.dst.offset == datetime.timedelta(hours=-4) assert rule.dst_start is None assert rule.dst_end is None def test_dst_rules() -> None: """Test daylight savings start/end value.""" rule = tz_rule.parse_tz_rule("EST+5EDT,M3.2.0/2,M11.1.0/2") assert rule.std.name == "EST" assert rule.std.offset == datetime.timedelta(hours=-5) assert rule.dst assert rule.dst.name == "EDT" assert rule.dst.offset == datetime.timedelta(hours=-4) assert rule.dst_start assert isinstance(rule.dst_start, tz_rule.RuleDate) assert rule.dst_start.month == 3 assert rule.dst_start.week_of_month == 2 assert rule.dst_start.day_of_week == 0 assert rule.dst_start.time == datetime.timedelta(hours=2) assert rule.dst_end assert isinstance(rule.dst_end, tz_rule.RuleDate) assert rule.dst_end.month == 11 assert rule.dst_end.week_of_month == 1 assert rule.dst_end.day_of_week == 0 assert rule.dst_end.time == datetime.timedelta(hours=2) assert next( iter(rule.dst_start.as_rrule(datetime.datetime(2022, 1, 1))) ) == datetime.datetime(2022, 3, 13, 2, 0, 0) assert next( iter(rule.dst_end.as_rrule(datetime.datetime(2022, 1, 1))) ) == datetime.datetime(2022, 11, 6, 2, 0, 0) assert rule.dst_start.rrule_str == "FREQ=YEARLY;BYMONTH=3;BYDAY=2SU" assert rule.dst_end.rrule_str == "FREQ=YEARLY;BYMONTH=11;BYDAY=1SU" def test_dst_implement_time_rules() -> None: """Test daylight savings values rules with no explicit time.""" rule = tz_rule.parse_tz_rule("EST+5EDT,M3.2.0,M11.1.0") assert rule.std.name == "EST" assert rule.std.offset == datetime.timedelta(hours=-5) assert rule.dst assert rule.dst.name == "EDT" assert rule.dst.offset == datetime.timedelta(hours=-4) assert rule.dst_start assert isinstance(rule.dst_start, tz_rule.RuleDate) assert rule.dst_start.month == 3 assert rule.dst_start.week_of_month == 2 assert rule.dst_start.day_of_week == 0 assert rule.dst_start.time == datetime.timedelta(hours=2) assert rule.dst_end assert isinstance(rule.dst_end, tz_rule.RuleDate) assert rule.dst_end.month == 11 assert rule.dst_end.week_of_month == 1 assert rule.dst_end.day_of_week == 0 assert rule.dst_end.time == datetime.timedelta(hours=2) @pytest.mark.parametrize( "tz_string", [ "", "1234", "EST+5EDT,M3.2.0/2", "EST+5EDT,M3.2.0/2,M11.1.0/2,M3", "EST+5EDT,3.2.0/2,M11.1.0/2", "EST+5EDT,M3.2/2,M11.1.0/2", "EST+5EDT,M3.2.0.4/2,M11.1.0/2", ], ) def test_invalid(tz_string: str) -> None: """Test an invalid rule occurrence""" with pytest.raises(ValueError, match="Unable to parse TZ string"): tz_rule.parse_tz_rule(tz_string) def test_tz_offset() -> None: """Test standard time offset with hours and minutes.""" rule = tz_rule.parse_tz_rule("<-03>3<-02>,M3.5.0/-2,M10.5.0/-1") assert rule.std.name == "<-03>" assert rule.std.offset == datetime.timedelta(hours=-3) assert rule.dst assert rule.dst.name == "<-02>" assert rule.dst.offset == datetime.timedelta(hours=-2) assert rule.dst_start assert isinstance(rule.dst_start, tz_rule.RuleDate) assert rule.dst_start.month == 3 assert rule.dst_start.week_of_month == 5 assert rule.dst_start.day_of_week == 0 assert rule.dst_start.time == datetime.timedelta(hours=-2) assert rule.dst_end assert isinstance(rule.dst_end, tz_rule.RuleDate) assert rule.dst_end.month == 10 assert rule.dst_end.week_of_month == 5 assert rule.dst_end.day_of_week == 0 assert rule.dst_end.time == datetime.timedelta(hours=-1) def test_iran_rule_offset() -> None: """Test a more complex timezone rule.""" rule = tz_rule.parse_tz_rule("<+0330>-3:30<+0430>,J79/24,J263/24") assert rule.std.name == "<+0330>" assert rule.std.offset == datetime.timedelta(hours=3, minutes=30) assert rule.dst assert rule.dst.name == "<+0430>" assert rule.dst.offset == datetime.timedelta(hours=4, minutes=30) assert rule.dst_start assert isinstance(rule.dst_start, tz_rule.RuleDay) assert rule.dst_start.day_of_year == 79 assert rule.dst_start.time == datetime.timedelta(hours=24) assert rule.dst_end assert isinstance(rule.dst_end, tz_rule.RuleDay) assert rule.dst_end.day_of_year == 263 assert rule.dst_end.time == datetime.timedelta(hours=24) def test_parse_tz_rule_benchmark(benchmark: Any) -> None: """Benchmark to measure the speed of parsing.""" def parse() -> None: tz_rule.parse_tz_rule("EST5") tz_rule.parse_tz_rule("EST+5EDT,M3.2.0,M11.1.0") tz_rule.parse_tz_rule("<-03>3<-02>,M3.5.0/-2,M10.5.0/-1") benchmark(parse) allenporter-ical-fe8800b/tests/tzif/test_tzif.py000066400000000000000000000037611510550726100221010ustar00rootroot00000000000000"""Tests for the tzif library.""" import datetime import pytest from ical.tzif import timezoneinfo, tzif V1_HEADER = b"".join( [ b"\x54\x5a\x69\x66", # magic b"\x00", # version b"\x00\x00\x00\x00", # pad b"\x00\x00\x00\x00", b"\x00\x00\x00\x00", b"\x00\x00\x00", b"\x00\x00\x00\x01" # isutccnt b"\x00\x00\x00\x01" # isstdcnt b"\x00\x00\x00\x1b" # isleapcnt b"\x00\x00\x00\x00" # timecnt b"\x00\x00\x00\x01" # typecnt b"\x00\x00\x00\x04", # charcnt ] ) @pytest.mark.parametrize( "header,match", [ ( b"\x00" + V1_HEADER[1:], "did not contain magic", ), ( V1_HEADER[0:23] + b"\x07" + V1_HEADER[24:], "UTC/local indicators in datablock mismatched", ), ( V1_HEADER[0:27] + b"\x07" + V1_HEADER[28:], "standard/wall indicators in datablock mismatched", ), ( V1_HEADER[0:23] + b"\x00" + V1_HEADER[24:27] + b"\x00" + V1_HEADER[28:39] + b"\x00" + V1_HEADER[40:], "Local time records in block is zero", ), ( V1_HEADER[0:43] + b"\x00", "octets is zero", ), ], ) def test_invalid_header(header: bytes, match: str) -> None: """Tests a TZif header with an invalid typecnt.""" assert len(header) == len(V1_HEADER) with pytest.raises(ValueError, match=match): tzif.read_tzif(header) def test_tzif() -> None: """Tests for tzif parser.""" result = timezoneinfo.read("America/Los_Angeles") assert len(result.transitions) > 0 assert result.rule assert result.rule.std assert result.rule.std.name == "PST" assert result.rule.std.offset == datetime.timedelta(hours=-8) assert result.rule.dst assert result.rule.dst.name == "PDT" assert result.rule.dst.offset == datetime.timedelta(hours=-7) allenporter-ical-fe8800b/tests/tzif/testdata/000077500000000000000000000000001510550726100213165ustar00rootroot00000000000000allenporter-ical-fe8800b/tests/tzif/testdata/rfc8536-v1.txt000066400000000000000000000262221510550726100235070ustar00rootroot00000000000000+-------+---------------+------------------+------------------------+ | File | Data Octets | Record Name / | Field Value | | Offset| (hexadecimal) | Field Name | | +-------+---------------+------------------+------------------------+ | 000 | 54 5a 69 66 | magic | "TZif" | | 004 | 00 | version | 0 (1) | | 005 | 00 00 00 00 | | | | | 00 00 00 00 | | | | | 00 00 00 00 | | | | | 00 00 00 | | | | 020 | 00 00 00 01 | isutccnt | 1 | | 024 | 00 00 00 01 | isstdcnt | 1 | | 028 | 00 00 00 1b | isleapcnt | 27 | | 032 | 00 00 00 00 | timecnt | 0 | | 036 | 00 00 00 01 | typecnt | 1 | | 040 | 00 00 00 04 | charcnt | 4 | | | | | | | | | localtimetype[0] | | | 044 | 00 00 00 00 | utcoff | 00:00 | | 048 | 00 | isdst | 0 (no) | | 049 | 00 | desigidx | 0 | | | | | | | 050 | 55 54 43 00 | designations[0] | "UTC" | | | | | | | | | leapsecond[0] | | | 054 | 04 b2 58 00 | occurrence | 78796800 | | | | | (1972-06-30T23:59:60Z) | | 058 | 00 00 00 01 | correction | 1 | | | | | | | | | leapsecond[1] | | | 062 | 05 a4 ec 01 | occurrence | 94694401 | | | | | (1972-12-31T23:59:60Z) | | 066 | 00 00 00 02 | correction | 2 | | | | | | | | | leapsecond[2] | | | 070 | 07 86 1f 82 | occurrence | 126230402 | | | | | (1973-12-31T23:59:60Z) | | 074 | 00 00 00 03 | correction | 3 | | | | | | | | | leapsecond[3] | | | 078 | 09 67 53 03 | occurrence | 157766403 | | | | | (1974-12-31T23:59:60Z) | | 082 | 00 00 00 04 | correction | 4 | | | | | | | | | leapsecond[4] | | | 086 | 0b 48 86 84 | occurrence | 189302404 | | | | | (1975-12-31T23:59:60Z) | | 090 | 00 00 00 05 | correction | 5 | | | | | | | | | leapsecond[5] | | | 094 | 0d 2b 0b 85 | occurrence | 220924805 | | | | | (1976-12-31T23:59:60Z) | | 098 | 00 00 00 06 | correction | 6 | | | | | | | | | leapsecond[6] | | | 102 | 0f 0c 3f 06 | occurrence | 252460806 | | | | | (1977-12-31T23:59:60Z) | | 106 | 00 00 00 07 | correction | 7 | | | | | | | | | leapsecond[7] | | | 110 | 10 ed 72 87 | occurrence | 283996807 | | | | | (1978-12-31T23:59:60Z) | | 114 | 00 00 00 08 | correction | 8 | | | | | | | | | leapsecond[8] | | | 118 | 12 ce a6 08 | occurrence | 315532808 | | | | | (1979-12-31T23:59:60Z) | | 122 | 00 00 00 09 | correction | 9 | | | | | | | | | leapsecond[9] | | | 126 | 15 9f ca 89 | occurrence | 362793609 | | | | | (1981-06-30T23:59:60Z) | | 130 | 00 00 00 0a | correction | 10 | | | | | | | | | leapsecond[10] | | | 134 | 17 80 fe 0a | occurrence | 394329610 | | | | | (1982-06-30T23:59:60Z) | | 138 | 00 00 00 0b | correction | 11 | | | | | | | | | leapsecond[11] | | | 142 | 19 62 31 8b | occurrence | 425865611 | | | | | (1983-06-30T23:59:60Z) | | 146 | 00 00 00 0c | correction | 12 | | | | | | | | | leapsecond[12] | | | 150 | 1d 25 ea 0c | occurrence | 489024012 | | | | | (1985-06-30T23:59:60Z) | | 154 | 00 00 00 0d | correction | 13 | | | | | | | | | leapsecond[13] | | | 158 | 21 da e5 0d | occurrence | 567993613 | | | | | (1987-12-31T23:59:60Z) | | 162 | 00 00 00 0e | correction | 14 | | | | | | | | | leapsecond[14] | | | 166 | 25 9e 9d 8e | occurrence | 631152014 | | | | | (1989-12-31T23:59:60Z) | | 170 | 00 00 00 0f | correction | 15 | | | | | | | | | leapsecond[15] | | | 174 | 27 7f d1 0f | occurrence | 662688015 | | | | | (1990-12-31T23:59:60Z) | | 178 | 00 00 00 10 | correction | 16 | | | | | | | | | leapsecond[16] | | | 182 | 2a 50 f5 90 | occurrence | 709948816 | | | | | (1992-06-30T23:59:60Z) | | 186 | 00 00 00 11 | correction | 17 | | | | | | | | | leapsecond[17] | | | 190 | 2c 32 29 11 | occurrence | 741484817 | | | | | (1993-06-30T23:59:60Z) | | 194 | 00 00 00 12 | correction | 18 | | | | | | | | | leapsecond[18] | | | 198 | 2e 13 5c 92 | occurrence | 773020818 | | | | | (1994-06-30T23:59:60Z) | | 202 | 00 00 00 13 | correction | 19 | | | | | | | | | leapsecond[19] | | | 206 | 30 e7 24 13 | occurrence | 820454419 | | | | | (1995-12-31T23:59:60Z) | | 210 | 00 00 00 14 | correction | 20 | | | | | | | | | leapsecond[20] | | | 214 | 33 b8 48 94 | occurrence | 867715220 | | | | | (1997-06-30T23:59:60Z) | | 218 | 00 00 00 15 | correction | 21 | | | | | | | | | leapsecond[21] | | | 222 | 36 8c 10 15 | occurrence | 915148821 | | | | | (1998-12-31T23:59:60Z) | | 226 | 00 00 00 16 | correction | 22 | | | | | | | | | leapsecond[22] | | | 230 | 43 b7 1b 96 | occurrence | 1136073622 | | | | | (2005-12-31T23:59:60Z) | | 234 | 00 00 00 17 | correction | 23 | | | | | | | | | leapsecond[23] | | | 238 | 49 5c 07 97 | occurrence | 1230768023 | | | | | (2008-12-31T23:59:60Z) | | 242 | 00 00 00 18 | correction | 24 | | | | | | | | | leapsecond[24] | | | 246 | 4f ef 93 18 | occurrence | 1341100824 | | | | | (2012-06-30T23:59:60Z) | | 250 | 00 00 00 19 | correction | 25 | | | | | | | | | leapsecond[25] | | | 254 | 55 93 2d 99 | occurrence | 1435708825 | | | | | (2015-06-30T23:59:60Z) | | 258 | 00 00 00 1a | correction | 26 | | | | | | | | | leapsecond[26] | | | 262 | 58 68 46 9a | occurrence | 1483228826 | | | | | (2016-12-31T23:59:60Z) | | 266 | 00 00 00 1b | correction | 27 | | | | | | | 270 | 00 | UT/local[0] | 0 (local) | | | | | | | 271 | 00 | standard/wall[0] | 0 (wall) | +-------+---------------+------------------+------------------------+ allenporter-ical-fe8800b/tests/tzif/testdata/rfc8536-v2.txt000066400000000000000000000305761510550726100235170ustar00rootroot00000000000000+--------+--------------+------------------+------------------------+ | File | Hexadecimal | Record Name / | Field Value | | Offset | Octets | Field Name | | +--------+--------------+------------------+------------------------+ | 000 | 54 5a 69 66 | magic | "TZif" | | 004 | 32 | version | '2' (2) | | 005 | 00 00 00 00 | | | | | 00 00 00 00 | | | | | 00 00 00 00 | | | | | 00 00 00 | | | | 020 | 00 00 00 06 | isutccnt | 6 | | 024 | 00 00 00 06 | isstdcnt | 6 | | 028 | 00 00 00 00 | isleapcnt | 0 | | 032 | 00 00 00 07 | timecnt | 7 | | 036 | 00 00 00 06 | typecnt | 6 | | 040 | 00 00 00 14 | charcnt | 20 | | | | | | | 044 | 80 00 00 00 | trans time[0] | -2147483648 | | | | | (1901-12-13T20:45:52Z) | | 048 | bb 05 43 48 | trans time[1] | -1157283000 | | | | | (1933-04-30T12:30:00Z) | | 052 | bb 21 71 58 | trans time[2] | -1155436200 | | | | | (1933-05-21T21:30:00Z) | | 056 | cb 89 3d c8 | trans time[3] | -880198200 | | | | | (1942-02-09T12:30:00Z) | | 060 | d2 23 f4 70 | trans time[4] | -769395600 | | | | | (1945-08-14T23:00:00Z) | | 064 | d2 61 49 38 | trans time[5] | -765376200 | | | | | (1945-09-30T11:30:00Z) | | 068 | d5 8d 73 48 | trans time[6] | -712150200 | | | | | (1947-06-08T12:30:00Z) | | | | | | | 072 | 01 | trans type[0] | 1 | | 073 | 02 | trans type[1] | 2 | | 074 | 01 | trans type[2] | 1 | | 075 | 03 | trans type[3] | 3 | | 076 | 04 | trans type[4] | 4 | | 077 | 01 | trans type[5] | 1 | | 078 | 05 | trans type[6] | 5 | | | | | | | | | localtimetype[0] | | | 079 | ff ff 6c 02 | utcoff | -37886 (-10:21:26) | | 083 | 00 | isdst | 0 (no) | | 084 | 00 | desigidx | 0 | | | | | | | | | localtimetype[1] | | | 085 | ff ff 6c 58 | utcoff | -37800 (-10:30) | | 089 | 00 | isdst | 0 (no) | | 090 | 04 | desigidx | 4 | | | | | | | | | localtimetype[2] | | | 091 | ff ff 7a 68 | utcoff | -34200 (-09:30) | | 095 | 01 | isdst | 1 (yes) | | 096 | 08 | desigidx | 8 | | | | | | | | | localtimetype[3] | | | 097 | ff ff 7a 68 | utcoff | -34200 (-09:30) | | 101 | 01 | isdst | 1 (yes) | | 102 | 0c | desigidx | 12 | | | | | | | | | localtimetype[4] | | | 103 | ff ff 7a 68 | utcoff | -34200 (-09:30) | | 107 | 01 | isdst | 1 (yes) | | 108 | 10 | desigidx | 16 | | | | | | | | | localtimetype[5] | | | 109 | ff ff 73 60 | utcoff | -36000 (-10:00) | | 113 | 00 | isdst | 0 (no) | | 114 | 04 | desigidx | 4 | | | | | | | 115 | 4c 4d 54 00 | designations[0] | "LMT" | | 119 | 48 53 54 00 | designations[4] | "HST" | | 123 | 48 44 54 00 | designations[8] | "HDT" | | 127 | 48 57 54 00 | designations[12] | "HWT" | | 131 | 48 50 54 00 | designations[16] | "HPT" | | | | | | | 135 | 00 | UT/local[0] | 1 (UT) | | 136 | 00 | UT/local[1] | 0 (local) | | 137 | 00 | UT/local[2] | 0 (local) | | 138 | 00 | UT/local[3] | 0 (local) | | 139 | 01 | UT/local[4] | 1 (UT) | | 140 | 00 | UT/local[5] | 0 (local) | | | | | | | 141 | 00 | standard/wall[0] | 1 (standard) | | 142 | 00 | standard/wall[1] | 0 (wall) | | 143 | 00 | standard/wall[2] | 0 (wall) | | 144 | 00 | standard/wall[3] | 0 (wall) | | 145 | 01 | standard/wall[4] | 1 (standard) | | 146 | 00 | standard/wall[5] | 0 (wall) | | | | | | | 147 | 54 5a 69 66 | magic | "TZif" | | 151 | 32 | version | '2' (2) | | 152 | 00 00 00 00 | | | | | 00 00 00 00 | | | | | 00 00 00 00 | | | | | 00 00 00 | | | | 167 | 00 00 00 06 | isutccnt | 6 | | 171 | 00 00 00 06 | isstdcnt | 6 | | 175 | 00 00 00 00 | isleapcnt | 0 | | 179 | 00 00 00 07 | timecnt | 7 | | 183 | 00 00 00 06 | typecnt | 6 | | 187 | 00 00 00 14 | charcnt | 20 | | | | | | | 191 | ff ff ff ff | trans time[0] | -2334101314 | | | 74 e0 70 be | | (1896-01-13T22:31:26Z) | | 199 | ff ff ff ff | trans time[1] | -1157283000 | | | bb 05 43 48 | | (1933-04-30T12:30:00Z) | | 207 | ff ff ff ff | trans time[2] | -1155436200 | | | bb 21 71 58 | | (1933-05-21T21:30:00Z) | | 215 | ff ff ff ff | trans time[3] | -880198200 | | | cb 89 3d c8 | | (1942-02-09T12:30:00Z) | | 223 | ff ff ff ff | trans time[4] | -769395600 | | | d2 23 f4 70 | | (1945-08-14T23:00:00Z) | | 231 | ff ff ff ff | trans time[5] | -765376200 | | | d2 61 49 38 | | (1945-09-30T11:30:00Z) | | 239 | ff ff ff ff | trans time[6] | -712150200 | | | d5 8d 73 48 | | (1947-06-08T12:30:00Z) | | | | | | | 247 | 01 | trans type[0] | 1 | | 248 | 02 | trans type[1] | 2 | | 249 | 01 | trans type[2] | 1 | | 250 | 03 | trans type[3] | 3 | | 251 | 04 | trans type[4] | 4 | | 252 | 01 | trans type[5] | 1 | | 253 | 05 | trans type[6] | 5 | | | | | | | | | localtimetype[0] | | | 254 | ff ff 6c 02 | utcoff | -37886 (-10:21:26) | | 258 | 00 | isdst | 0 (no) | | 259 | 00 | desigidx | 0 | | | | | | | | | localtimetype[1] | | | 260 | ff ff 6c 58 | utcoff | -37800 (-10:30) | | 264 | 00 | isdst | 0 (no) | | 265 | 04 | desigidx | 4 | | | | | | | | | localtimetype[2] | | | 266 | ff ff 7a 68 | utcoff | -34200 (-09:30) | | 270 | 01 | isdst | 1 (yes) | | 271 | 08 | desigidx | 8 | | | | | | | | | localtimetype[3] | | | 272 | ff ff 7a 68 | utcoff | -34200 (-09:30) | | 276 | 01 | isdst | 1 (yes) | | 277 | 0c | desigidx | 12 | | | | | | | | | localtimetype[4] | | | 278 | ff ff 7a 68 | utcoff | -34200 (-09:30) | | 282 | 01 | isdst | 1 (yes) | | 283 | 10 | desigidx | 16 | | | | | | | | | localtimetype[5] | | | 284 | ff ff 73 60 | utcoff | -36000 (-10:00) | | 288 | 00 | isdst | 0 (no) | | 289 | 04 | desigidx | 4 | | | | | | | 290 | 4c 4d 54 00 | designations[0] | "LMT" | | 294 | 48 53 54 00 | designations[4] | "HST" | | 298 | 48 44 54 00 | designations[8] | "HDT" | | 302 | 48 57 54 00 | designations[12] | "HWT" | | 306 | 48 50 54 00 | designations[16] | "HPT" | | | | | | | 310 | 00 | UT/local[0] | 0 (local) | | 311 | 00 | UT/local[1] | 0 (local) | | 312 | 00 | UT/local[2] | 0 (local) | | 313 | 00 | UT/local[3] | 0 (local) | | 314 | 01 | UT/local[4] | 1 (UT) | | 315 | 00 | UT/local[5] | 0 (local) | | | | | | | 316 | 00 | standard/wall[0] | 0 (wall) | | 317 | 00 | standard/wall[1] | 0 (wall) | | 318 | 00 | standard/wall[2] | 0 (wall) | | 319 | 00 | standard/wall[3] | 0 (wall) | | 320 | 01 | standard/wall[4] | 1 (standard) | | 321 | 00 | standard/wall[5] | 0 (wall) | | | | | | | 322 | 0a | NL | '\n' | | 323 | 48 53 54 31 | TZ string | "HST10" | | | 30 | | | | 328 | 0a | NL | '\n' | +--------+--------------+------------------+------------------------+