diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..020e162830387d0d5835cf46a2be5f19a835e70b --- /dev/null +++ b/.gitignore @@ -0,0 +1,135 @@ +# 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/ + +# Test notebook +test.ipynb + +# Wandb +wandb/ \ No newline at end of file diff --git a/README.md b/README.md index 15213371f1631fa7107bd68d0d40b3b58bb47379..e36a64977502216eb6f5dfa176cb735a3f0639dd 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,95 @@ ---- -title: HTK -emoji: 🏒 -colorFrom: purple -colorTo: purple -sdk: streamlit -sdk_version: 1.36.0 -app_file: app.py -pinned: false ---- - -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +--- +title: HTK +app_file: app1.py +sdk: gradio +sdk_version: 4.36.1 +--- +# MRC-RetroReader + +## Introduction + +MRC-RetroReader is a machine reading comprehension (MRC) model designed for reading comprehension tasks. The model leverages advanced neural network architectures to provide high accuracy in understanding and responding to textual queries. + +## Table of Contents + +- [Introduction](#introduction) +- [Table of Contents](#table-of-contents) +- [Installation](#installation) +- [Usage](#usage) +- [Features](#features) +- [Dependencies](#dependencies) +- [Configuration](#configuration) +- [Documentation](#documentation) +- [Examples](#examples) +- [Troubleshooting](#troubleshooting) +- [Contributors](#contributors) +- [License](#license) + +## Installation + +1. Clone the repository: + ``` + git clone https://github.com/phanhoang1803/MRC-RetroReader.git + cd MRC-RetroReader + ``` +2. Install the required dependencies: + ``` + pip install -r requirements.txt + ``` + +## Usage + +- For notebooks: to running automatically, turn off wandb, warning if necessary: +``` +wandb off +import warnings +warnings.filterwarnings('ignore') +``` +- To train the model using the SQuAD v2 dataset: +``` +python train_squad_v2.py --config path-to-yaml-file --module intensive --batch_size batch_size +``` + +## Features + +- High accuracy MRC model +- Easy to train on custom datasets +- Configurable parameters for model tuning + +## Dependencies + +- Python 3.x +- PyTorch +- Transformers +- Tokenizers + +For a full list of dependencies, see `requirements.txt`. + +## Configuration + +Configuration files can be found in the `configs` directory. Adjust the parameters in these files to customize the model training and evaluation. + +## Documentation + +For detailed documentation, refer to the `documentation` directory. This includes: +- Model architecture +- Training procedures +- Evaluation metrics + +## Examples + +Example training and evaluation scripts are provided in the repository. To train on the SQuAD v2 dataset: + + +## Troubleshooting + +For common issues and their solutions, refer to the `troubleshooting guide`. + +## Contributors + +- phanhoang1803 + +## License + +This project is licensed under the MIT License. See the `LICENSE` file for details. + diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..f920e8c69823a0e7f3e2f7cb9630d51a27bc4300 --- /dev/null +++ b/app.py @@ -0,0 +1,115 @@ +import streamlit as st +import io +import os +import yaml +import pyarrow +import tokenizers + +os.environ["TOKENIZERS_PARALLELISM"] = "true" + +# Setting page config to wide mode +st.set_page_config(layout="wide") + +@st.cache_resource +def from_library(): + from retro_reader import RetroReader + from retro_reader import constants as C + return C, RetroReader + +C, RetroReader = from_library() + +my_hash_func = { + io.TextIOWrapper: lambda _: None, + pyarrow.lib.Buffer: lambda _: 0, + tokenizers.Tokenizer: lambda _: None, + tokenizers.AddedToken: lambda _: None +} + +@st.cache_resource(hash_funcs=my_hash_func) +def load_en_electra_base_model(): + config_file = "configs/inference_en_electra_base.yaml" + return RetroReader.load(config_file=config_file) + +@st.cache_resource(hash_funcs=my_hash_func) +def load_en_electra_large_model(): + config_file = "configs/inference_en_electra_large.yaml" + return RetroReader.load(config_file=config_file) + +RETRO_READER_HOST = { + "google/electra-base-discriminator": load_en_electra_base_model(), + "google/electra-large-discriminator": load_en_electra_large_model(), +} + +def display_top_predictions(nbest_preds, top_k=10): + # Assuming nbest_preds might be a dictionary with a key that contains the list + if not isinstance(nbest_preds, list): + nbest_preds = nbest_preds['id-01'] # Adjust key as per actual structure + + sorted_preds = sorted(nbest_preds, key=lambda x: x['probability'], reverse=True)[:top_k] + st.markdown("### Top Predictions") + for i, pred in enumerate(sorted_preds, 1): + st.markdown(f"**{i}. {pred['text']}** - Probability: {pred['probability']*100:.2f}%") + +def main(): + # Sidebar Introduction + st.sidebar.title("πŸ“ Welcome to Retro Reader") + st.sidebar.write(""" + MRC-RetroReader is a machine reading comprehension (MRC) model designed for reading comprehension tasks. The model leverages advanced neural network architectures to provide high accuracy in understanding and responding to textual queries. + """) + image_url = "img.jpg" # Replace this URL with your actual image URL or local path + st.sidebar.image(image_url, use_column_width=True) + st.sidebar.title("Contributors") + st.sidebar.write(""" + - Phan Van Hoang + - Pham Long Khanh + """) + + st.title("Retrospective Reader Demo") + st.markdown("## Model name🚨") + option = st.selectbox( + label="Choose the model used in retro reader", + options=( + "[1] google/electra-base-discriminator", + "[2] google/electra-large-discriminator" + ), + index=1, + ) + lang_code, model_name = option.split(" ") + retro_reader = RETRO_READER_HOST[model_name] + + lang_prefix = "EN" + height = 200 + return_submodule_outputs = True + + with st.form(key="my_form"): + st.markdown("## Type your query ❓") + query = st.text_input( + label="", + value=getattr(C, f"{lang_prefix}_EXAMPLE_QUERY"), + max_chars=None, + help=getattr(C, f"{lang_prefix}_QUERY_HELP_TEXT"), + ) + st.markdown("## Type your query πŸ’¬") + context = st.text_area( + label="", + value=getattr(C, f"{lang_prefix}_EXAMPLE_CONTEXTS"), + height=height, + max_chars=None, + help=getattr(C, f"{lang_prefix}_CONTEXT_HELP_TEXT"), + ) + submit_button = st.form_submit_button(label="Submit") + + if submit_button: + with st.spinner("πŸ•’ Please wait.."): + outputs = retro_reader(query=query, context=context, return_submodule_outputs=return_submodule_outputs) + answer, score = outputs[0]["id-01"], outputs[1] + if not answer: + answer = "No answer" + st.markdown("## πŸ“œ Results") + st.write(answer) + if return_submodule_outputs: + score_ext, nbest_preds, score_diff = outputs[2:] + display_top_predictions(nbest_preds) + +if __name__ == "__main__": + main() diff --git a/app1.py b/app1.py new file mode 100644 index 0000000000000000000000000000000000000000..78b3648a6dc8007412974ee8ddee1c9170cc440e --- /dev/null +++ b/app1.py @@ -0,0 +1,45 @@ +import gradio as gr +import io +import os +import yaml +import pyarrow +import tokenizers +from retro_reader import RetroReader + +os.environ["TOKENIZERS_PARALLELISM"] = "true" + +def from_library(): + from retro_reader import constants as C + return C, RetroReader + +C, RetroReader = from_library() + +# Assuming RetroReader.load is a method from your imports +def load_model(config_path): + return RetroReader.load(config_file=config_path) + +# Loading models +model_base = load_model("configs/inference_en_electra_base.yaml") +model_large = load_model("configs/inference_en_electra_large.yaml") + +def retro_reader_demo(query, context, model_choice): + model = model_base if model_choice == "Base" else model_large + outputs = model(query=query, context=context, return_submodule_outputs=True) + answer = outputs[0]["id-01"] if outputs[0]["id-01"] else "No answer found" + return answer + +# Gradio app interface +iface = gr.Interface( + fn=retro_reader_demo, + inputs=[ + gr.Textbox(label="Query", placeholder="Type your query here..."), + gr.Textbox(label="Context", placeholder="Provide the context here...", lines=10), + gr.Radio(choices=["Base", "Large"], label="Model Choice") + ], + outputs=gr.Textbox(label="Answer"), + title="Retrospective Reader Demo", + description="This interface uses the RetroReader model to perform reading comprehension tasks." +) + +if __name__ == "__main__": + iface.launch(share=True) diff --git a/configs/inference_electra_base.yaml b/configs/inference_electra_base.yaml new file mode 100644 index 0000000000000000000000000000000000000000..2446a5a6818c9f8e3dbed9b6be1105292eba220f --- /dev/null +++ b/configs/inference_electra_base.yaml @@ -0,0 +1,41 @@ +RetroDataModelArguments: + + # DataArguments + max_seq_length: 512 + max_answer_length: 30 + doc_stride: 128 + return_token_type_ids: True + pad_to_max_length: True + preprocessing_num_workers: 5 + overwrite_cache: False + version_2_with_negative: True + null_score_diff_threshold: 0.0 + rear_threshold: 0.0 + n_best_size: 20 + use_choice_logits: False + start_n_top: -1 + end_n_top: -1 + beta1: 1 + beta2: 1 + best_cof: 1 + + # ModelArguments + use_auth_token: False + + # SketchModelArguments + sketch_revision: en-electra-large-sketch + sketch_model_name: jinmang2/retro-reader + sketch_architectures: ElectraForSequenceClassification + + # IntensiveModelArguments + intensive_revision: en-electra-large-intensive + intensive_model_name: jinmang2/retro-reader + intensive_architectures: ElectraForQuestionAnsweringAVPool + + +TrainingArguments: + output_dir: outputs + no_cuda: True # If you want to use cuda, + # change `no_cuda` to False and `fp16` to True + per_device_train_batch_size: 1 + per_device_eval_batch_size: 12 \ No newline at end of file diff --git a/configs/inference_en_electra_base.yaml b/configs/inference_en_electra_base.yaml new file mode 100644 index 0000000000000000000000000000000000000000..80f00cad34a061b74abe7dd03f8fda009ab3ca70 --- /dev/null +++ b/configs/inference_en_electra_base.yaml @@ -0,0 +1,43 @@ +RetroDataModelArguments: + + # DataArguments + max_seq_length: 512 + max_answer_length: 30 + doc_stride: 128 + return_token_type_ids: True + pad_to_max_length: True + preprocessing_num_workers: 5 + overwrite_cache: False + version_2_with_negative: True + null_score_diff_threshold: 0.0 + rear_threshold: 0.0 + n_best_size: 20 + use_choice_logits: False + start_n_top: -1 + end_n_top: -1 + beta1: 1 + beta2: 1 + best_cof: 1 + + # ModelArguments + use_auth_token: False + + # SketchModelArguments + sketch_revision: en-electra-base-sketch + sketch_model_name: faori/retro_reeader + # sketch_model_mode: transfer + sketch_architectures: ElectraForSequenceClassification + + # IntensiveModelArguments + intensive_revision: en-electra-base-intensive + intensive_model_name: faori/retro_reeader + # intensive_model_mode: transfer + intensive_architectures: ElectraForQuestionAnsweringAVPool + + +TrainingArguments: + output_dir: outputs + no_cuda: True # If you want to use cuda, + # change `no_cuda` to False and `fp16` to True + per_device_train_batch_size: 1 + per_device_eval_batch_size: 12 \ No newline at end of file diff --git a/configs/inference_en_electra_large.yaml b/configs/inference_en_electra_large.yaml new file mode 100644 index 0000000000000000000000000000000000000000..c6c84ad18943dcecacc0e482b83eba97deee4985 --- /dev/null +++ b/configs/inference_en_electra_large.yaml @@ -0,0 +1,43 @@ +RetroDataModelArguments: + + # DataArguments + max_seq_length: 512 + max_answer_length: 30 + doc_stride: 128 + return_token_type_ids: True + pad_to_max_length: True + preprocessing_num_workers: 5 + overwrite_cache: False + version_2_with_negative: True + null_score_diff_threshold: 0.0 + rear_threshold: 0.0 + n_best_size: 20 + use_choice_logits: False + start_n_top: -1 + end_n_top: -1 + beta1: 1 + beta2: 1 + best_cof: 1 + + # ModelArguments + use_auth_token: False + + # SketchModelArguments + sketch_revision: en-electra-large-sketch + sketch_model_name: jinmang2/retro-reader + # sketch_model_mode: transfer + sketch_architectures: ElectraForSequenceClassification + + # IntensiveModelArguments + intensive_revision: en-electra-large-intensive + intensive_model_name: jinmang2/retro-reader + # intensive_model_mode: transfer + intensive_architectures: ElectraForQuestionAnsweringAVPool + + +TrainingArguments: + output_dir: outputs + no_cuda: True # If you want to use cuda, + # change `no_cuda` to False and `fp16` to True + per_device_train_batch_size: 1 + per_device_eval_batch_size: 12 \ No newline at end of file diff --git a/configs/train_distilbert.yaml b/configs/train_distilbert.yaml new file mode 100644 index 0000000000000000000000000000000000000000..22fbfa8275a5423e07d4e7fcf8a66a439c28c041 --- /dev/null +++ b/configs/train_distilbert.yaml @@ -0,0 +1,54 @@ +RetroDataModelArguments: + + # DataArguments + max_seq_length: 512 + max_answer_length: 30 + doc_stride: 128 + return_token_type_ids: True + pad_to_max_length: True + preprocessing_num_workers: 5 + overwrite_cache: False + version_2_with_negative: True + null_score_diff_threshold: 0.0 + rear_threshold: 0.0 + n_best_size: 20 + use_choice_logits: False + start_n_top: -1 + end_n_top: -1 + beta1: 1 + beta2: 1 + best_cof: 1 + + # SketchModelArguments + sketch_model_name: distilbert/distilbert-base-uncased + # sketch_model_mode: transfer + sketch_architectures: DistilBertForSequenceClassification + + # IntensiveModelArguments + intensive_model_name: distilbert-base-uncased + intensive_model_mode: transfer + intensive_architectures: DistilBertForQuestionAnsweringAVPool + + +TrainingArguments: + # report_to: wandb + run_name: squadv2-distilbert-base-sketch,squadv2-distilbert-base-intensive + output_dir: outputs + overwrite_output_dir: False + learning_rate: 2e-5 + evaluation_strategy: epoch + save_strategy: steps # Save checkpoints every specified number of steps + # save_steps: 5000 # Save model checkpoints every 5000 steps + save_steps: 5000 + save_total_limit: 2 # Maximum number of checkpoints to keep + # load_best_model_at_end: True # Disable to avoid loading the best model at the end + # no need to specify checkpoint_dir, it defaults to output_dir + # no need to specify logging_dir, it defaults to output_dir + per_device_train_batch_size: 64 + per_device_eval_batch_size: 64 + num_train_epochs: 10.0 + # no need to specify metric_for_best_model for resuming from checkpoints + no_cuda: False + fp16: True + warmup_ratio: 0.1 + weight_decay: 0.01 diff --git a/configs/train_en_electra_base_finetune.yaml b/configs/train_en_electra_base_finetune.yaml new file mode 100644 index 0000000000000000000000000000000000000000..c31fa4f3a37660d760d6959f0e9d25b57e70929b --- /dev/null +++ b/configs/train_en_electra_base_finetune.yaml @@ -0,0 +1,54 @@ +RetroDataModelArguments: + + # DataArguments + max_seq_length: 512 + max_answer_length: 30 + doc_stride: 128 + return_token_type_ids: True + pad_to_max_length: True + preprocessing_num_workers: 5 + overwrite_cache: False + version_2_with_negative: True + null_score_diff_threshold: 0.0 + rear_threshold: 0.0 + n_best_size: 20 + use_choice_logits: False + start_n_top: -1 + end_n_top: -1 + beta1: 1 + beta2: 1 + best_cof: 1 + + # SketchModelArguments + sketch_model_name: google/electra-base-discriminator + sketch_architectures: ElectraForSequenceClassification + + # IntensiveModelArguments + intensive_model_name: google/electra-base-discriminator + intensive_model_mode: finetune + intensive_architectures: ElectraForQuestionAnsweringAVPool + + +TrainingArguments: + # report_to: wandb + run_name: squadv2-electra-base-sketch,squadv2-electra-base-intensive + output_dir: outputs + overwrite_output_dir: False + learning_rate: 2e-5 + evaluation_strategy: epoch + save_strategy: steps # Save checkpoints every specified number of steps + # save_steps: 5000 # Save model checkpoints every 5000 steps + save_steps: 5000 + save_total_limit: 2 # Maximum number of checkpoints to keep + # load_best_model_at_end: True # Disable to avoid loading the best model at the end + # no need to specify checkpoint_dir, it defaults to output_dir + # no need to specify logging_dir, it defaults to output_dir + per_device_train_batch_size: 64 + per_device_eval_batch_size: 64 + num_train_epochs: 10.0 + # no need to specify metric_for_best_model for resuming from checkpoints + no_cuda: False + fp16: True + warmup_ratio: 0.1 + weight_decay: 0.01 + diff --git a/configs/train_en_electra_base_transfer.yaml b/configs/train_en_electra_base_transfer.yaml new file mode 100644 index 0000000000000000000000000000000000000000..c02525ab5e316f19f99d1e14210b9fa14f80b139 --- /dev/null +++ b/configs/train_en_electra_base_transfer.yaml @@ -0,0 +1,53 @@ +RetroDataModelArguments: + + # DataArguments + max_seq_length: 512 + max_answer_length: 30 + doc_stride: 128 + return_token_type_ids: True + pad_to_max_length: True + preprocessing_num_workers: 5 + overwrite_cache: False + version_2_with_negative: True + null_score_diff_threshold: 0.0 + rear_threshold: 0.0 + n_best_size: 20 + use_choice_logits: False + start_n_top: -1 + end_n_top: -1 + beta1: 1 + beta2: 1 + best_cof: 1 + + # SketchModelArguments + sketch_model_name: google/electra-base-discriminator + sketch_architectures: ElectraForSequenceClassification + + # IntensiveModelArguments + intensive_model_name: google/electra-base-discriminator + intensive_model_mode: transfer + intensive_architectures: ElectraForQuestionAnsweringAVPool + + +TrainingArguments: + # report_to: wandb + run_name: squadv2-electra-base-sketch,squadv2-electra-base-intensive + output_dir: outputs + overwrite_output_dir: False + learning_rate: 2e-5 + evaluation_strategy: epoch + save_strategy: steps # Save checkpoints every specified number of steps + # save_steps: 5000 # Save model checkpoints every 5000 steps + save_steps: 5000 + save_total_limit: 2 # Maximum number of checkpoints to keep + # load_best_model_at_end: True # Disable to avoid loading the best model at the end + # no need to specify checkpoint_dir, it defaults to output_dir + # no need to specify logging_dir, it defaults to output_dir + per_device_train_batch_size: 512 + per_device_eval_batch_size: 512 + num_train_epochs: 10.0 + # no need to specify metric_for_best_model for resuming from checkpoints + no_cuda: False + fp16: True + warmup_ratio: 0.1 + weight_decay: 0.01 diff --git a/configs/train_en_electra_large_finetune.yaml b/configs/train_en_electra_large_finetune.yaml new file mode 100644 index 0000000000000000000000000000000000000000..9a433188c67dac4303a44c5320ef45d68ddf9363 --- /dev/null +++ b/configs/train_en_electra_large_finetune.yaml @@ -0,0 +1,53 @@ +RetroDataModelArguments: + + # DataArguments + max_seq_length: 512 + max_answer_length: 30 + doc_stride: 128 + return_token_type_ids: True + pad_to_max_length: True + preprocessing_num_workers: 5 + overwrite_cache: False + version_2_with_negative: True + null_score_diff_threshold: 0.0 + rear_threshold: 0.0 + n_best_size: 20 + use_choice_logits: False + start_n_top: -1 + end_n_top: -1 + beta1: 1 + beta2: 1 + best_cof: 1 + + # SketchModelArguments + sketch_model_name: google/electra-large-discriminator + sketch_architectures: ElectraForSequenceClassification + + # IntensiveModelArguments + intensive_model_name: google/electra-large-discriminator + intensive_model_mode: finetune + intensive_architectures: ElectraForQuestionAnsweringAVPool + + +TrainingArguments: + # report_to: wandb + run_name: squadv2-electra-base-sketch,squadv2-electra-base-intensive + output_dir: outputs + overwrite_output_dir: False + learning_rate: 2e-5 + evaluation_strategy: epoch + save_strategy: steps # Save checkpoints every specified number of steps + # save_steps: 5000 # Save model checkpoints every 5000 steps + save_steps: 5000 + save_total_limit: 2 # Maximum number of checkpoints to keep + # load_best_model_at_end: True # Disable to avoid loading the best model at the end + # no need to specify checkpoint_dir, it defaults to output_dir + # no need to specify logging_dir, it defaults to output_dir + per_device_train_batch_size: 8 + per_device_eval_batch_size: 8 + num_train_epochs: 10.0 + # no need to specify metric_for_best_model for resuming from checkpoints + no_cuda: False + fp16: True + warmup_ratio: 0.1 + weight_decay: 0.01 diff --git a/configs/train_en_electra_large_transfer.yaml b/configs/train_en_electra_large_transfer.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a5687f640638ddfa046fd28b00d2a6e1d08fc70c --- /dev/null +++ b/configs/train_en_electra_large_transfer.yaml @@ -0,0 +1,54 @@ +RetroDataModelArguments: + + # DataArguments + max_seq_length: 512 + max_answer_length: 30 + doc_stride: 128 + return_token_type_ids: True + pad_to_max_length: True + preprocessing_num_workers: 5 + overwrite_cache: False + version_2_with_negative: True + null_score_diff_threshold: 0.0 + rear_threshold: 0.0 + n_best_size: 20 + use_choice_logits: False + start_n_top: -1 + end_n_top: -1 + beta1: 1 + beta2: 1 + best_cof: 1 + + # SketchModelArguments + sketch_model_name: google/electra-large-discriminator + # sketch_model_mode: transfer + sketch_architectures: ElectraForSequenceClassification + + # IntensiveModelArguments + intensive_model_name: google/electra-large-discriminator + intensive_model_mode: transfer + intensive_architectures: ElectraForQuestionAnsweringAVPool + + +TrainingArguments: + # report_to: wandb + run_name: squadv2-electra-base-sketch,squadv2-electra-base-intensive + output_dir: outputs + overwrite_output_dir: False + learning_rate: 2e-5 + evaluation_strategy: epoch + save_strategy: steps # Save checkpoints every specified number of steps + # save_steps: 5000 # Save model checkpoints every 5000 steps + save_steps: 5000 + save_total_limit: 2 # Maximum number of checkpoints to keep + # load_best_model_at_end: True # Disable to avoid loading the best model at the end + # no need to specify checkpoint_dir, it defaults to output_dir + # no need to specify logging_dir, it defaults to output_dir + per_device_train_batch_size: 512 + per_device_eval_batch_size: 512 + num_train_epochs: 10.0 + # no need to specify metric_for_best_model for resuming from checkpoints + no_cuda: False + fp16: True + warmup_ratio: 0.1 + weight_decay: 0.01 \ No newline at end of file diff --git a/configs/train_roberta_base_finetune.yaml b/configs/train_roberta_base_finetune.yaml new file mode 100644 index 0000000000000000000000000000000000000000..10c36c293c3f17cf82d86e0828af471b0372eee4 --- /dev/null +++ b/configs/train_roberta_base_finetune.yaml @@ -0,0 +1,53 @@ +RetroDataModelArguments: + + # DataArguments + max_seq_length: 512 + max_answer_length: 30 + doc_stride: 128 + return_token_type_ids: True + pad_to_max_length: True + preprocessing_num_workers: 5 + overwrite_cache: False + version_2_with_negative: True + null_score_diff_threshold: 0.0 + rear_threshold: 0.0 + n_best_size: 20 + use_choice_logits: False + start_n_top: -1 + end_n_top: -1 + beta1: 1 + beta2: 1 + best_cof: 1 + + # SketchModelArguments + sketch_model_name: FacebookAI/roberta-base + sketch_architectures: RobertaForSequenceClassification + + # IntensiveModelArguments + intensive_model_name: FacebookAI/roberta-base + intensive_model_mode: finetune + intensive_architectures: RobertaForQuestionAnsweringAVPool + + +TrainingArguments: + # report_to: wandb + run_name: squadv2-roberta-base-sketch,squadv2-roberta-base-intensive + output_dir: outputs + overwrite_output_dir: False + learning_rate: 2e-5 + evaluation_strategy: epoch + save_strategy: steps # Save checkpoints every specified number of steps + # save_steps: 5000 # Save model checkpoints every 5000 steps + save_steps: 5000 + save_total_limit: 2 # Maximum number of checkpoints to keep + # load_best_model_at_end: True # Disable to avoid loading the best model at the end + # no need to specify checkpoint_dir, it defaults to output_dir + # no need to specify logging_dir, it defaults to output_dir + per_device_train_batch_size: 64 + per_device_eval_batch_size: 64 + num_train_epochs: 10.0 + # no need to specify metric_for_best_model for resuming from checkpoints + no_cuda: False + fp16: True + warmup_ratio: 0.1 + weight_decay: 0.01 diff --git a/configs/train_roberta_base_transfer.yaml b/configs/train_roberta_base_transfer.yaml new file mode 100644 index 0000000000000000000000000000000000000000..2fba7eb4191c4c58f8c94e52c525c45254fc151d --- /dev/null +++ b/configs/train_roberta_base_transfer.yaml @@ -0,0 +1,53 @@ +RetroDataModelArguments: + + # DataArguments + max_seq_length: 512 + max_answer_length: 30 + doc_stride: 128 + return_token_type_ids: True + pad_to_max_length: True + preprocessing_num_workers: 5 + overwrite_cache: False + version_2_with_negative: True + null_score_diff_threshold: 0.0 + rear_threshold: 0.0 + n_best_size: 20 + use_choice_logits: False + start_n_top: -1 + end_n_top: -1 + beta1: 1 + beta2: 1 + best_cof: 1 + + # SketchModelArguments + sketch_model_name: FacebookAI/roberta-base + sketch_architectures: RobertaForSequenceClassification + + # IntensiveModelArguments + intensive_model_name: FacebookAI/roberta-base + intensive_model_mode: transfer + intensive_architectures: RobertaForQuestionAnsweringAVPool + + +TrainingArguments: + # report_to: wandb + run_name: squadv2-roberta-base-sketch,squadv2-roberta-base-intensive + output_dir: outputs + overwrite_output_dir: False + learning_rate: 2e-5 + evaluation_strategy: epoch + save_strategy: steps # Save checkpoints every specified number of steps + # save_steps: 5000 # Save model checkpoints every 5000 steps + save_steps: 5000 + save_total_limit: 2 # Maximum number of checkpoints to keep + # load_best_model_at_end: True # Disable to avoid loading the best model at the end + # no need to specify checkpoint_dir, it defaults to output_dir + # no need to specify logging_dir, it defaults to output_dir + per_device_train_batch_size: 512 + per_device_eval_batch_size: 512 + num_train_epochs: 10.0 + # no need to specify metric_for_best_model for resuming from checkpoints + no_cuda: False + fp16: True + warmup_ratio: 0.1 + weight_decay: 0.01 diff --git a/configs/train_roberta_large_finetune.yaml b/configs/train_roberta_large_finetune.yaml new file mode 100644 index 0000000000000000000000000000000000000000..53f7867d1318cf5a4464c78d91493d5362607290 --- /dev/null +++ b/configs/train_roberta_large_finetune.yaml @@ -0,0 +1,53 @@ +RetroDataModelArguments: + + # DataArguments + max_seq_length: 512 + max_answer_length: 30 + doc_stride: 128 + return_token_type_ids: True + pad_to_max_length: True + preprocessing_num_workers: 5 + overwrite_cache: False + version_2_with_negative: True + null_score_diff_threshold: 0.0 + rear_threshold: 0.0 + n_best_size: 20 + use_choice_logits: False + start_n_top: -1 + end_n_top: -1 + beta1: 1 + beta2: 1 + best_cof: 1 + + # SketchModelArguments + sketch_model_name: FacebookAI/roberta-large + sketch_architectures: RobertaForSequenceClassification + + # IntensiveModelArguments + intensive_model_name: FacebookAI/roberta-large + intensive_model_mode: finetune + intensive_architectures: RobertaForQuestionAnsweringAVPool + + +TrainingArguments: + # report_to: wandb + run_name: squadv2-roberta-base-sketch,squadv2-roberta-base-intensive + output_dir: outputs + overwrite_output_dir: False + learning_rate: 2e-5 + evaluation_strategy: epoch + save_strategy: steps # Save checkpoints every specified number of steps + # save_steps: 5000 # Save model checkpoints every 5000 steps + save_steps: 5000 + save_total_limit: 2 # Maximum number of checkpoints to keep + # load_best_model_at_end: True # Disable to avoid loading the best model at the end + # no need to specify checkpoint_dir, it defaults to output_dir + # no need to specify logging_dir, it defaults to output_dir + per_device_train_batch_size: 512 + per_device_eval_batch_size: 512 + num_train_epochs: 10.0 + # no need to specify metric_for_best_model for resuming from checkpoints + no_cuda: False + fp16: True + warmup_ratio: 0.1 + weight_decay: 0.01 diff --git a/configs/train_roberta_large_transfer.yaml b/configs/train_roberta_large_transfer.yaml new file mode 100644 index 0000000000000000000000000000000000000000..810d14f93e2e6ca76b58d086d6aca941e2663410 --- /dev/null +++ b/configs/train_roberta_large_transfer.yaml @@ -0,0 +1,53 @@ +RetroDataModelArguments: + + # DataArguments + max_seq_length: 512 + max_answer_length: 30 + doc_stride: 128 + return_token_type_ids: True + pad_to_max_length: True + preprocessing_num_workers: 5 + overwrite_cache: False + version_2_with_negative: True + null_score_diff_threshold: 0.0 + rear_threshold: 0.0 + n_best_size: 20 + use_choice_logits: False + start_n_top: -1 + end_n_top: -1 + beta1: 1 + beta2: 1 + best_cof: 1 + + # SketchModelArguments + sketch_model_name: FacebookAI/roberta-large + sketch_architectures: RobertaForSequenceClassification + + # IntensiveModelArguments + intensive_model_name: FacebookAI/roberta-large + intensive_model_mode: transfer + intensive_architectures: RobertaForQuestionAnsweringAVPool + + +TrainingArguments: + # report_to: wandb + run_name: squadv2-roberta-base-sketch,squadv2-roberta-base-intensive + output_dir: outputs + overwrite_output_dir: False + learning_rate: 2e-5 + evaluation_strategy: epoch + save_strategy: steps # Save checkpoints every specified number of steps + # save_steps: 5000 # Save model checkpoints every 5000 steps + save_steps: 5000 + save_total_limit: 2 # Maximum number of checkpoints to keep + # load_best_model_at_end: True # Disable to avoid loading the best model at the end + # no need to specify checkpoint_dir, it defaults to output_dir + # no need to specify logging_dir, it defaults to output_dir + per_device_train_batch_size: 512 + per_device_eval_batch_size: 512 + num_train_epochs: 10.0 + # no need to specify metric_for_best_model for resuming from checkpoints + no_cuda: False + fp16: True + warmup_ratio: 0.1 + weight_decay: 0.01 diff --git a/evaluate_squad_v2.py b/evaluate_squad_v2.py new file mode 100644 index 0000000000000000000000000000000000000000..04cf7113a9778c7a4fd7e694f2eacceb4a7c6234 --- /dev/null +++ b/evaluate_squad_v2.py @@ -0,0 +1,136 @@ +import os +os.environ["TF_ENABLE_ONEDNN_OPTS"] = '0' + +from huggingface_hub import login + + +from typing import Union, Any, Dict +# from datasets.arrow_dataset import Batch + +import argparse +import datasets +from transformers.utils import logging, check_min_version +from transformers.utils.versions import require_version + +from retro_reader import RetroReader +from retro_reader.constants import EXAMPLE_FEATURES +import torch + +# Will error if the minimal version of Transformers is not installed. Remove at your own risks. +check_min_version("4.13.0.dev0") + +require_version("datasets>=1.8.0") + +logger = logging.get_logger(__name__) + + +def schema_integrate(example) -> Union[Dict, Any]: + title = example["title"] + question = example["question"] + context = example["context"] + guid = example["id"] + classtype = [""] * len(title) + dataset_name = source = ["squad_v2"] * len(title) + answers, is_impossible = [], [] + for answer_examples in example["answers"]: + if answer_examples["text"]: + answers.append(answer_examples) + is_impossible.append(False) + else: + answers.append({"text": [""], "answer_start": [-1]}) + is_impossible.append(True) + # The feature names must be sorted. + return { + "guid": guid, + "question": question, + "context": context, + "answers": answers, + "title": title, + "classtype": classtype, + "source": source, + "is_impossible": is_impossible, + "dataset": dataset_name, + } + + +# data augmentation for multiple answers +def data_aug_for_multiple_answers(examples) -> Union[Dict, Any]: + result = {key: [] for key in examples.keys()} + + def update(i, answers=None): + for key in result.keys(): + if key == "answers" and answers is not None: + result[key].append(answers) + else: + result[key].append(examples[key][i]) + + for i, (answers, unanswerable) in enumerate( + zip(examples["answers"], examples["is_impossible"]) + ): + answerable = not unanswerable + assert ( + len(answers["text"]) == len(answers["answer_start"]) or + answers["answer_start"][0] == -1 + ) + if answerable and len(answers["text"]) > 1: + for n_ans in range(len(answers["text"])): + ans = { + "text": [answers["text"][n_ans]], + "answer_start": [answers["answer_start"][n_ans]], + } + update(i, ans) + elif not answerable: + update(i, {"text": [], "answer_start": []}) + else: + update(i) + + return result + + +def main(args): + # Load SQuAD V2.0 dataset + print("Loading SQuAD v2.0 dataset ...") + squad_v2 = datasets.load_dataset("squad_v2") + + # TODO: Visualize a sample from the dataset + + # Integrate into the schema used in this library + # Note: The columns used for preprocessing are `question`, `context`, `answers` + # and `is_impossible`. The remaining columns are columns that exist to + # process other types of data. + + # Minize the dataset for debugging + if args.debug: + squad_v2["validation"] = squad_v2["validation"].select(range(5)) + + print("Integrating into the schema used in this library ...") + squad_v2 = squad_v2.map( + schema_integrate, + batched=True, + remove_columns=squad_v2.column_names["train"], + features=EXAMPLE_FEATURES, + ) + # Load Retro Reader + # features: parse arguments + # make train/eval dataset from examples + # load model from πŸ€— hub + # set sketch/intensive reader and rear verifier + print("Loading Retro Reader ...") + retro_reader = RetroReader.load( + config_file=args.configs, + device="cuda" if torch.cuda.is_available() else "cpu", + ) + + # Train + res = retro_reader.evaluate(squad_v2["validation"]) + print(res) + logger.warning("Train retrospective reader Done.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--configs", "-c", type=str, default="configs/inference_electra_base.yaml", help="config file path") + parser.add_argument("--batch_size", "-b", type=int, default=1024, help="batch size") + parser.add_argument("--debug", "-d", action="store_true", help="debug mode") + args = parser.parse_args() + main(args) \ No newline at end of file diff --git a/evaluation_scriptv2.0.py b/evaluation_scriptv2.0.py new file mode 100644 index 0000000000000000000000000000000000000000..e4c3a7c042c8402db65fef376273544a591d7fb9 --- /dev/null +++ b/evaluation_scriptv2.0.py @@ -0,0 +1,276 @@ +"""Official evaluation script for SQuAD version 2.0. + +In addition to basic functionality, we also compute additional statistics and +plot precision-recall curves if an additional na_prob.json file is provided. +This file is expected to map question ID's to the model's predicted probability +that a question is unanswerable. +""" +import argparse +import collections +import json +import numpy as np +import os +import re +import string +import sys + +OPTS = None + +def parse_args(): + parser = argparse.ArgumentParser('Official evaluation script for SQuAD version 2.0.') + parser.add_argument('data_file', metavar='data.json', help='Input data JSON file.') + parser.add_argument('pred_file', metavar='pred.json', help='Model predictions.') + parser.add_argument('--out-file', '-o', metavar='eval.json', + help='Write accuracy metrics to file (default is stdout).') + parser.add_argument('--na-prob-file', '-n', metavar='na_prob.json', + help='Model estimates of probability of no answer.') + parser.add_argument('--na-prob-thresh', '-t', type=float, default=1.0, + help='Predict "" if no-answer probability exceeds this (default = 1.0).') + parser.add_argument('--out-image-dir', '-p', metavar='out_images', default=None, + help='Save precision-recall curves to directory.') + parser.add_argument('--verbose', '-v', action='store_true') + if len(sys.argv) == 1: + parser.print_help() + sys.exit(1) + return parser.parse_args() + +def make_qid_to_has_ans(dataset): + qid_to_has_ans = {} + for article in dataset: + for p in article['paragraphs']: + for qa in p['qas']: + qid_to_has_ans[qa['id']] = bool(qa['answers']) + return qid_to_has_ans + +def normalize_answer(s): + """Lower text and remove punctuation, articles and extra whitespace.""" + def remove_articles(text): + regex = re.compile(r'\b(a|an|the)\b', re.UNICODE) + return re.sub(regex, ' ', text) + def white_space_fix(text): + return ' '.join(text.split()) + def remove_punc(text): + exclude = set(string.punctuation) + return ''.join(ch for ch in text if ch not in exclude) + def lower(text): + return text.lower() + return white_space_fix(remove_articles(remove_punc(lower(s)))) + +def get_tokens(s): + if not s: return [] + return normalize_answer(s).split() + +def compute_exact(a_gold, a_pred): + return int(normalize_answer(a_gold) == normalize_answer(a_pred)) + +def compute_f1(a_gold, a_pred): + gold_toks = get_tokens(a_gold) + pred_toks = get_tokens(a_pred) + common = collections.Counter(gold_toks) & collections.Counter(pred_toks) + num_same = sum(common.values()) + if len(gold_toks) == 0 or len(pred_toks) == 0: + # If either is no-answer, then F1 is 1 if they agree, 0 otherwise + return int(gold_toks == pred_toks) + if num_same == 0: + return 0 + precision = 1.0 * num_same / len(pred_toks) + recall = 1.0 * num_same / len(gold_toks) + f1 = (2 * precision * recall) / (precision + recall) + return f1 + +def get_raw_scores(dataset, preds): + exact_scores = {} + f1_scores = {} + for article in dataset: + for p in article['paragraphs']: + for qa in p['qas']: + qid = qa['id'] + gold_answers = [a['text'] for a in qa['answers'] + if normalize_answer(a['text'])] + if not gold_answers: + # For unanswerable questions, only correct answer is empty string + gold_answers = [''] + if qid not in preds: + print('Missing prediction for %s' % qid) + continue + a_pred = preds[qid] + # Take max over all gold answers + exact_scores[qid] = max(compute_exact(a, a_pred) for a in gold_answers) + f1_scores[qid] = max(compute_f1(a, a_pred) for a in gold_answers) + return exact_scores, f1_scores + +def apply_no_ans_threshold(scores, na_probs, qid_to_has_ans, na_prob_thresh): + new_scores = {} + for qid, s in scores.items(): + pred_na = na_probs[qid] > na_prob_thresh + if pred_na: + new_scores[qid] = float(not qid_to_has_ans[qid]) + else: + new_scores[qid] = s + return new_scores + +def make_eval_dict(exact_scores, f1_scores, qid_list=None): + if not qid_list: + total = len(exact_scores) + return collections.OrderedDict([ + ('exact', 100.0 * sum(exact_scores.values()) / total), + ('f1', 100.0 * sum(f1_scores.values()) / total), + ('total', total), + ]) + else: + total = len(qid_list) + return collections.OrderedDict([ + ('exact', 100.0 * sum(exact_scores[k] for k in qid_list) / total), + ('f1', 100.0 * sum(f1_scores[k] for k in qid_list) / total), + ('total', total), + ]) + +def merge_eval(main_eval, new_eval, prefix): + for k in new_eval: + main_eval['%s_%s' % (prefix, k)] = new_eval[k] + +def plot_pr_curve(precisions, recalls, out_image, title): + plt.step(recalls, precisions, color='b', alpha=0.2, where='post') + plt.fill_between(recalls, precisions, step='post', alpha=0.2, color='b') + plt.xlabel('Recall') + plt.ylabel('Precision') + plt.xlim([0.0, 1.05]) + plt.ylim([0.0, 1.05]) + plt.title(title) + plt.savefig(out_image) + plt.clf() + +def make_precision_recall_eval(scores, na_probs, num_true_pos, qid_to_has_ans, + out_image=None, title=None): + qid_list = sorted(na_probs, key=lambda k: na_probs[k]) + true_pos = 0.0 + cur_p = 1.0 + cur_r = 0.0 + precisions = [1.0] + recalls = [0.0] + avg_prec = 0.0 + for i, qid in enumerate(qid_list): + if qid_to_has_ans[qid]: + true_pos += scores[qid] + cur_p = true_pos / float(i+1) + cur_r = true_pos / float(num_true_pos) + if i == len(qid_list) - 1 or na_probs[qid] != na_probs[qid_list[i+1]]: + # i.e., if we can put a threshold after this point + avg_prec += cur_p * (cur_r - recalls[-1]) + precisions.append(cur_p) + recalls.append(cur_r) + if out_image: + plot_pr_curve(precisions, recalls, out_image, title) + return {'ap': 100.0 * avg_prec} + +def run_precision_recall_analysis(main_eval, exact_raw, f1_raw, na_probs, + qid_to_has_ans, out_image_dir): + if out_image_dir and not os.path.exists(out_image_dir): + os.makedirs(out_image_dir) + num_true_pos = sum(1 for v in qid_to_has_ans.values() if v) + if num_true_pos == 0: + return + pr_exact = make_precision_recall_eval( + exact_raw, na_probs, num_true_pos, qid_to_has_ans, + out_image=os.path.join(out_image_dir, 'pr_exact.png'), + title='Precision-Recall curve for Exact Match score') + pr_f1 = make_precision_recall_eval( + f1_raw, na_probs, num_true_pos, qid_to_has_ans, + out_image=os.path.join(out_image_dir, 'pr_f1.png'), + title='Precision-Recall curve for F1 score') + oracle_scores = {k: float(v) for k, v in qid_to_has_ans.items()} + pr_oracle = make_precision_recall_eval( + oracle_scores, na_probs, num_true_pos, qid_to_has_ans, + out_image=os.path.join(out_image_dir, 'pr_oracle.png'), + title='Oracle Precision-Recall curve (binary task of HasAns vs. NoAns)') + merge_eval(main_eval, pr_exact, 'pr_exact') + merge_eval(main_eval, pr_f1, 'pr_f1') + merge_eval(main_eval, pr_oracle, 'pr_oracle') + +def histogram_na_prob(na_probs, qid_list, image_dir, name): + if not qid_list: + return + x = [na_probs[k] for k in qid_list] + weights = np.ones_like(x) / float(len(x)) + plt.hist(x, weights=weights, bins=20, range=(0.0, 1.0)) + plt.xlabel('Model probability of no-answer') + plt.ylabel('Proportion of dataset') + plt.title('Histogram of no-answer probability: %s' % name) + plt.savefig(os.path.join(image_dir, 'na_prob_hist_%s.png' % name)) + plt.clf() + +def find_best_thresh(preds, scores, na_probs, qid_to_has_ans): + num_no_ans = sum(1 for k in qid_to_has_ans if not qid_to_has_ans[k]) + cur_score = num_no_ans + best_score = cur_score + best_thresh = 0.0 + qid_list = sorted(na_probs, key=lambda k: na_probs[k]) + for i, qid in enumerate(qid_list): + if qid not in scores: continue + if qid_to_has_ans[qid]: + diff = scores[qid] + else: + if preds[qid]: + diff = -1 + else: + diff = 0 + cur_score += diff + if cur_score > best_score: + best_score = cur_score + best_thresh = na_probs[qid] + return 100.0 * best_score / len(scores), best_thresh + +def find_all_best_thresh(main_eval, preds, exact_raw, f1_raw, na_probs, qid_to_has_ans): + best_exact, exact_thresh = find_best_thresh(preds, exact_raw, na_probs, qid_to_has_ans) + best_f1, f1_thresh = find_best_thresh(preds, f1_raw, na_probs, qid_to_has_ans) + main_eval['best_exact'] = best_exact + main_eval['best_exact_thresh'] = exact_thresh + main_eval['best_f1'] = best_f1 + main_eval['best_f1_thresh'] = f1_thresh + +def main(): + with open(OPTS.data_file) as f: + dataset_json = json.load(f) + dataset = dataset_json['data'] + with open(OPTS.pred_file) as f: + preds = json.load(f) + if OPTS.na_prob_file: + with open(OPTS.na_prob_file) as f: + na_probs = json.load(f) + else: + na_probs = {k: 0.0 for k in preds} + qid_to_has_ans = make_qid_to_has_ans(dataset) # maps qid to True/False + has_ans_qids = [k for k, v in qid_to_has_ans.items() if v] + no_ans_qids = [k for k, v in qid_to_has_ans.items() if not v] + exact_raw, f1_raw = get_raw_scores(dataset, preds) + exact_thresh = apply_no_ans_threshold(exact_raw, na_probs, qid_to_has_ans, + OPTS.na_prob_thresh) + f1_thresh = apply_no_ans_threshold(f1_raw, na_probs, qid_to_has_ans, + OPTS.na_prob_thresh) + out_eval = make_eval_dict(exact_thresh, f1_thresh) + if has_ans_qids: + has_ans_eval = make_eval_dict(exact_thresh, f1_thresh, qid_list=has_ans_qids) + merge_eval(out_eval, has_ans_eval, 'HasAns') + if no_ans_qids: + no_ans_eval = make_eval_dict(exact_thresh, f1_thresh, qid_list=no_ans_qids) + merge_eval(out_eval, no_ans_eval, 'NoAns') + if OPTS.na_prob_file: + find_all_best_thresh(out_eval, preds, exact_raw, f1_raw, na_probs, qid_to_has_ans) + if OPTS.na_prob_file and OPTS.out_image_dir: + run_precision_recall_analysis(out_eval, exact_raw, f1_raw, na_probs, + qid_to_has_ans, OPTS.out_image_dir) + histogram_na_prob(na_probs, has_ans_qids, OPTS.out_image_dir, 'hasAns') + histogram_na_prob(na_probs, no_ans_qids, OPTS.out_image_dir, 'noAns') + if OPTS.out_file: + with open(OPTS.out_file, 'w') as f: + json.dump(out_eval, f) + else: + print(json.dumps(out_eval, indent=2)) + +if __name__ == '__main__': + OPTS = parse_args() + if OPTS.out_image_dir: + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + main() \ No newline at end of file diff --git a/google-calendar-simple-api/.github/CODE_OF_CONDUCT.md b/google-calendar-simple-api/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000000000000000000000000000000000..6301f4c76c95658c865e864b9f6b9f2bedd482c9 --- /dev/null +++ b/google-calendar-simple-api/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# 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, socio-economic 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 +kuzmovich.goog@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. diff --git a/google-calendar-simple-api/.github/ISSUE_TEMPLATE/bug_report.md b/google-calendar-simple-api/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000000000000000000000000000000000..142ae7eca24b3aefac47ad8c84b0d259e546a2d3 --- /dev/null +++ b/google-calendar-simple-api/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,57 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +[READ and REMOVE: Please create an issue only if you think that it's something that needs a fix or you have a +suggestion/request for improvement. If you have a question or issue not caused by gcsa itself, please use +the [discussions page](https://github.com/kuzmoyev/google-calendar-simple-api/discussions).] + +## Bug description + +A clear and concise description of what the bug is. + +## To Reproduce + +Steps to reproduce the behavior: + +1. Installed the latest version with `pip install gcsa` +2. ... + +Code used: + +```python +from gcsa.google_calendar import GoogleCalendar + +... +``` + +## Error or unexpected output + +The whole traceback in case of an error: +``` +Traceback (most recent call last): +... +``` + +## Expected behavior + +A clear and concise description of what you expected to happen. + +## Screenshots + +If applicable, add screenshots to help explain your problem. + +## Tech: + +- OS: [e.g. Linux/Windows/MacOS] +- GCSA version: [e.g. 2.0.1] +- Python version: [e.g. 3.12] + +## Additional context + +Add any other context about the problem here. diff --git a/google-calendar-simple-api/.github/workflows/code-cov.yml b/google-calendar-simple-api/.github/workflows/code-cov.yml new file mode 100644 index 0000000000000000000000000000000000000000..a38c346593614f5f7489de1d55db7dd43d4bae86 --- /dev/null +++ b/google-calendar-simple-api/.github/workflows/code-cov.yml @@ -0,0 +1,31 @@ +name: Code coverage + +on: [pull_request] + +jobs: + run: + # Don't run on PRs from forks + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.12' + + - name: Install dependencies + run: pip install tox + + - name: Generate code coverage + run: tox -e coverage + + + - name: Post to GitHub + uses: 5monkeys/cobertura-action@master + with: + path: coverage.xml + repo_token: ${{ secrets.GITHUB_TOKEN }} + minimum_coverage: 75 + skip_covered: false diff --git a/google-calendar-simple-api/.github/workflows/tests.yml b/google-calendar-simple-api/.github/workflows/tests.yml new file mode 100644 index 0000000000000000000000000000000000000000..48178a43288344a9fde56cc299c65ee44a2f369c --- /dev/null +++ b/google-calendar-simple-api/.github/workflows/tests.yml @@ -0,0 +1,31 @@ +name: Tests + +on: + push: + branches: + - master + pull_request: + +jobs: + run: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [ '3.7', '3.8', '3.9', '3.10', '3.11', '3.12' ] + include: + - python-version: '3.12' + note: with-style-and-docs-checks + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install tox + run: pip install tox tox-gh-actions + + - name: Running tests + run: tox diff --git a/google-calendar-simple-api/.gitignore b/google-calendar-simple-api/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..89ac53c25bbc7b3c74d038f5215796bfa1c45a1a --- /dev/null +++ b/google-calendar-simple-api/.gitignore @@ -0,0 +1,18 @@ +# Created by .ignore support plugin (hsz.mobi) +.idea/ +credentials.json +token.pickle +venv/ +__pycache__ + +build +dist +.eggs +gcsa.egg-info +docs/html + +example.py +coverage.xml +.coverage +.DS_Store +.tox \ No newline at end of file diff --git a/google-calendar-simple-api/.readthedocs.yml b/google-calendar-simple-api/.readthedocs.yml new file mode 100644 index 0000000000000000000000000000000000000000..cc32d44980a35e12a42f95e85bda63559288f404 --- /dev/null +++ b/google-calendar-simple-api/.readthedocs.yml @@ -0,0 +1,16 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.12" + +sphinx: + configuration: docs/source/conf.py + +python: + install: + - method: pip + path: . + extra_requirements: + - docs diff --git a/google-calendar-simple-api/CONTRIBUTING.md b/google-calendar-simple-api/CONTRIBUTING.md new file mode 100644 index 0000000000000000000000000000000000000000..83c3ff570a97218c3187091a57edb85687055708 --- /dev/null +++ b/google-calendar-simple-api/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing to GCSA + +Welcome and thank you for considering contributing to *Google Calendar Simple API* open source project! + +Before contributing to this repository, please first discuss the change you wish to make via +[Issue](https://github.com/kuzmoyev/google-calendar-simple-api/issues), +[GitHub Discussion](https://github.com/kuzmoyev/google-calendar-simple-api/discussions), or [Discord](https://discord.gg/mRAegbwYKS). Don’t hesitate to ask! +Issue submissions, discussions, suggestions are as welcomed contributions as pull requests. + +## Steps to contribute changes + +1. [Fork](https://github.com/kuzmoyev/google-calendar-simple-api/fork) the repository +2. Clone it with `git clone git@github.com:{your_username}/google-calendar-simple-api.git` +3. Install dependencies if needed with `pip install -e .` (or `pip install -e ".[dev]"` if you want to run tests, compile documentation, etc.). +Use [virtualenv](https://virtualenv.pypa.io/en/latest/) to avoid polluting your global python +4. Make and commit the changes. Add `closes #{issue_number}` to commit message if applies +5. Run the tests with `tox` (these will be run on pull request): + * `tox` - all the tests + * `tox -e pytest` - unit tests + * `tox -e flake8` - style check + * `tox -e sphinx` - docs compilation test +6. Push +7. Create pull request + * towards `dev` branch if the changes require a new GCSA version (i.e. changes in [gcsa](https://github.com/kuzmoyev/google-calendar-simple-api/tree/master/gcsa) module) + * towards `master` branch if they don't (e.x. changes in README, docs, tests) + +## While contributing + +* Follow the [Code of conduct](https://github.com/kuzmoyev/google-calendar-simple-api/blob/master/.github/CODE_OF_CONDUCT.md) +* Follow the [pep8](https://peps.python.org/pep-0008/) and the code style of the project (use your best judgement) +* Add documentation of your changes to code and/or to [read-the-docs](https://github.com/kuzmoyev/google-calendar-simple-api/tree/master/docs/source) if needed (use your best judgement) +* Add [tests](https://github.com/kuzmoyev/google-calendar-simple-api/tree/master/tests) if needed (use your best judgement) diff --git a/google-calendar-simple-api/LICENSE b/google-calendar-simple-api/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..911ea4a6bf3e736fb6a680fb77022e7ccc11ed97 --- /dev/null +++ b/google-calendar-simple-api/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/google-calendar-simple-api/README.rst b/google-calendar-simple-api/README.rst new file mode 100644 index 0000000000000000000000000000000000000000..973b483e1bb86813802286651cb47fbb205ab350 --- /dev/null +++ b/google-calendar-simple-api/README.rst @@ -0,0 +1,92 @@ +Google Calendar Simple API +========================== + +.. image:: https://badge.fury.io/py/gcsa.svg + :target: https://badge.fury.io/py/gcsa + :alt: PyPi Package + +.. image:: https://readthedocs.org/projects/google-calendar-simple-api/badge/?version=latest + :target: https://google-calendar-simple-api.readthedocs.io/en/latest/?badge=latest + :alt: Documentation Status + +.. image:: https://github.com/kuzmoyev/Google-Calendar-Simple-API/workflows/Tests/badge.svg + :target: https://github.com/kuzmoyev/Google-Calendar-Simple-API/actions + :alt: Tests + +.. image:: https://badgen.net/badge/icon/discord?icon=discord&label + :target: https://discord.gg/mRAegbwYKS + :alt: Discord + + +`Google Calendar Simple API` or `gcsa` is a library that simplifies event and calendar management in Google Calendars. +It is a Pythonic object oriented adapter for the official API. See the full `documentation`_. + +Installation +------------ + +.. code-block:: bash + + pip install gcsa + +See `Getting started page`_ for more details and installation options. + +Example usage +------------- + +List events +~~~~~~~~~~~ + +.. code-block:: python + + from gcsa.google_calendar import GoogleCalendar + + calendar = GoogleCalendar('your_email@gmail.com') + for event in calendar: + print(event) + + +Create event +~~~~~~~~~~~~ + +.. code-block:: python + + from gcsa.event import Event + + event = Event( + 'The Glass Menagerie', + start=datetime(2020, 7, 10, 19, 0), + location='ZΓ‘hΕ™ebskΓ‘ 468/21', + minutes_before_popup_reminder=15 + ) + calendar.add_event(event) + + +Create recurring event +~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from gcsa.recurrence import Recurrence, DAILY + + event = Event( + 'Breakfast', + start=date(2020, 7, 16), + recurrence=Recurrence.rule(freq=DAILY) + ) + calendar.add_event(event) + + +**Suggestion**: use beautiful_date_ to create `date` and `datetime` objects in your +projects (*because its beautiful... just like you*). + + +References +---------- + +Template for `setup.py` was taken from `kennethreitz/setup.py`_ + + +.. _documentation: https://google-calendar-simple-api.readthedocs.io/en/latest/?badge=latest +.. _`Getting started page`: https://google-calendar-simple-api.readthedocs.io/en/latest/getting_started.html +.. _beautiful_date: https://github.com/kuzmoyev/beautiful-date +.. _`kennethreitz/setup.py`: https://github.com/kennethreitz/setup.py diff --git a/google-calendar-simple-api/build/lib/gcsa/__init__.py b/google-calendar-simple-api/build/lib/gcsa/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/google-calendar-simple-api/build/lib/gcsa/_resource.py b/google-calendar-simple-api/build/lib/gcsa/_resource.py new file mode 100644 index 0000000000000000000000000000000000000000..818e4a5ff926769847059ac0c777e7ad3e863baf --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/_resource.py @@ -0,0 +1,8 @@ +from abc import ABC, abstractmethod + + +class Resource(ABC): + @property + @abstractmethod + def id(self): + pass diff --git a/google-calendar-simple-api/build/lib/gcsa/_services/__init__.py b/google-calendar-simple-api/build/lib/gcsa/_services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/google-calendar-simple-api/build/lib/gcsa/_services/acl_service.py b/google-calendar-simple-api/build/lib/gcsa/_services/acl_service.py new file mode 100644 index 0000000000000000000000000000000000000000..fb6f867d7d74a270ae18b14b36de44542203a772 --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/_services/acl_service.py @@ -0,0 +1,143 @@ +from typing import Iterable, Union + +from gcsa._services.base_service import BaseService +from gcsa.acl import AccessControlRule +from gcsa.serializers.acl_rule_serializer import ACLRuleSerializer + + +class ACLService(BaseService): + """Access Control List management methods of the `GoogleCalendar`""" + + def get_acl_rules( + self, + calendar_id: str = None, + show_deleted: bool = False + ) -> Iterable[AccessControlRule]: + """Returns the rules in the access control list for the calendar. + + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + :param show_deleted: + Whether to include deleted ACLs in the result. Deleted ACLs are represented by role equal to "none". + Deleted ACLs will always be included if syncToken is provided. Optional. The default is False. + + :return: + Iterable of `AccessControlRule` objects + """ + calendar_id = calendar_id or self.default_calendar + yield from self._list_paginated( + self.service.acl().list, + serializer_cls=ACLRuleSerializer, + calendarId=calendar_id, + **{ + 'showDeleted': show_deleted, + } + ) + + def get_acl_rule( + self, + rule_id: str, + calendar_id: str = None + ) -> AccessControlRule: + """Returns an access control rule + + :param rule_id: + ACL rule identifier. + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + + :return: + The corresponding `AccessControlRule` object + """ + calendar_id = calendar_id or self.default_calendar + acl_rule_resource = self.service.acl().get( + calendarId=calendar_id, + ruleId=rule_id + ).execute() + return ACLRuleSerializer.to_object(acl_rule_resource) + + def add_acl_rule( + self, + acl_rule: AccessControlRule, + send_notifications: bool = True, + calendar_id: str = None + ): + """Adds access control rule + + :param acl_rule: + AccessControlRule object. + :param send_notifications: + Whether to send notifications about the calendar sharing change. The default is True. + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + + :return: + Created access control rule with id. + """ + calendar_id = calendar_id or self.default_calendar + body = ACLRuleSerializer.to_json(acl_rule) + acl_rule_json = self.service.acl().insert( + calendarId=calendar_id, + body=body, + sendNotifications=send_notifications + ).execute() + return ACLRuleSerializer.to_object(acl_rule_json) + + def update_acl_rule( + self, + acl_rule: AccessControlRule, + send_notifications: bool = True, + calendar_id: str = None + ): + """Updates given access control rule + + :param acl_rule: + AccessControlRule object. + :param send_notifications: + Whether to send notifications about the calendar sharing change. The default is True. + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + + :return: + Updated access control rule. + """ + calendar_id = calendar_id or self.default_calendar + acl_id = self._get_resource_id(acl_rule) + body = ACLRuleSerializer.to_json(acl_rule) + acl_json = self.service.acl().update( + calendarId=calendar_id, + ruleId=acl_id, + body=body, + sendNotifications=send_notifications + ).execute() + return ACLRuleSerializer.to_object(acl_json) + + def delete_acl_rule( + self, + acl_rule: Union[AccessControlRule, str], + calendar_id: str = None + ): + """Deletes access control rule. + + :param acl_rule: + Access control rule's ID or `AccessControlRule` object with set `acl_id`. + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + """ + calendar_id = calendar_id or self.default_calendar + acl_id = self._get_resource_id(acl_rule) + + self.service.acl().delete( + calendarId=calendar_id, + ruleId=acl_id + ).execute() diff --git a/google-calendar-simple-api/build/lib/gcsa/_services/authentication.py b/google-calendar-simple-api/build/lib/gcsa/_services/authentication.py new file mode 100644 index 0000000000000000000000000000000000000000..f348a3e8c8b66b59a5eec21efcbc9b53916d8a05 --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/_services/authentication.py @@ -0,0 +1,144 @@ +import pickle +import os.path +import glob +from typing import List + +from googleapiclient import discovery +from google_auth_oauthlib.flow import InstalledAppFlow +from google.auth.transport.requests import Request +from google.auth.credentials import Credentials + + +class AuthenticatedService: + """Handles authentication of the `GoogleCalendar`""" + + _READ_WRITE_SCOPES = 'https://www.googleapis.com/auth/calendar' + _LIST_ORDERS = ("startTime", "updated") + + def __init__( + self, + *, + credentials: Credentials = None, + credentials_path: str = None, + token_path: str = None, + save_token: bool = True, + read_only: bool = False, + authentication_flow_host: str = 'localhost', + authentication_flow_port: int = 8080, + authentication_flow_bind_addr: str = None + ): + """ + Specify ``credentials`` to use in requests or ``credentials_path`` and ``token_path`` to get credentials from. + + :param credentials: + Credentials with token and refresh token. + If specified, ``credentials_path``, ``token_path``, and ``save_token`` are ignored. + If not specified, credentials are retrieved from "token.pickle" file (specified in ``token_path`` or + default path) or with authentication flow using secret from "credentials.json" ("client_secret_*.json") + (specified in ``credentials_path`` or default path) + :param credentials_path: + Path to "credentials.json" ("client_secret_*.json") file. + Default: ~/.credentials/credentials.json or ~/.credentials/client_secret*.json + :param token_path: + Existing path to load the token from, or path to save the token after initial authentication flow. + Default: "token.pickle" in the same directory as the credentials_path + :param save_token: + Whether to pickle token after authentication flow for future uses + :param read_only: + If require read only access. Default: False + :param authentication_flow_host: + Host to receive response during authentication flow + :param authentication_flow_port: + Port to receive response during authentication flow + :param authentication_flow_bind_addr: + Optional IP address for the redirect server to listen on when it is not the same as host + (e.g. in a container) + """ + + if credentials: + self.credentials = self._ensure_refreshed(credentials) + else: + credentials_path = credentials_path or self._get_default_credentials_path() + credentials_dir, credentials_file = os.path.split(credentials_path) + token_path = token_path or os.path.join(credentials_dir, 'token.pickle') + scopes = [self._READ_WRITE_SCOPES + ('.readonly' if read_only else '')] + + self.credentials = self._get_credentials( + token_path, + credentials_dir, + credentials_file, + scopes, + save_token, + authentication_flow_host, + authentication_flow_port, + authentication_flow_bind_addr + ) + + self.service = discovery.build('calendar', 'v3', credentials=self.credentials) + + @staticmethod + def _ensure_refreshed( + credentials: Credentials + ) -> Credentials: + if not credentials.valid and credentials.expired: + credentials.refresh(Request()) + return credentials + + @staticmethod + def _get_credentials( + token_path: str, + credentials_dir: str, + credentials_file: str, + scopes: List[str], + save_token: bool, + host: str, + port: int, + bind_addr: str + ) -> Credentials: + credentials = None + + if os.path.exists(token_path): + with open(token_path, 'rb') as token_file: + credentials = pickle.load(token_file) + + if not credentials or not credentials.valid: + if credentials and credentials.expired and credentials.refresh_token: + credentials.refresh(Request()) + else: + credentials_path = os.path.join(credentials_dir, credentials_file) + flow = InstalledAppFlow.from_client_secrets_file(credentials_path, scopes) + credentials = flow.run_local_server(host=host, port=port, bind_addr=bind_addr) + + if save_token: + with open(token_path, 'wb') as token_file: + pickle.dump(credentials, token_file) + + return credentials + + @staticmethod + def _get_default_credentials_path() -> str: + """Checks if `.credentials` folder in home directory exists and contains `credentials.json` or + `client_secret*.json` file. + + :raises ValueError: if `.credentials` folder does not exist, none of `credentials.json` or `client_secret*.json` + files do not exist, or there are multiple `client_secret*.json` files. + :return: expanded path to `credentials.json` or `client_secret*.json` file + """ + home_dir = os.path.expanduser('~') + credential_dir = os.path.join(home_dir, '.credentials') + if not os.path.exists(credential_dir): + raise FileNotFoundError(f'Default credentials directory "{credential_dir}" does not exist.') + credential_path = os.path.join(credential_dir, 'credentials.json') + if os.path.exists(credential_path): + return credential_path + else: + credentials_files = glob.glob(credential_dir + '/client_secret*.json') + if len(credentials_files) > 1: + raise ValueError(f"Multiple credential files found in {credential_dir}.\n" + f"Try specifying the credentials file, e.x.:\n" + f"GoogleCalendar(credentials_path='{credentials_files[0]}')") + elif not credentials_files: + raise FileNotFoundError(f'Credentials file (credentials.json or client_secret*.json)' + f'not found in the default path: "{credential_dir}".') + else: + return credentials_files[0] diff --git a/google-calendar-simple-api/build/lib/gcsa/_services/base_service.py b/google-calendar-simple-api/build/lib/gcsa/_services/base_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e31c4ccf3825a28ffb99922175fd1e49cec805c1 --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/_services/base_service.py @@ -0,0 +1,61 @@ +from typing import Callable, Type, Union + +from gcsa._resource import Resource +from gcsa._services.authentication import AuthenticatedService + + +class BaseService(AuthenticatedService): + def __init__(self, default_calendar, *args, **kwargs): + """ + :param default_calendar: + Users email address or name/id of the calendar. Default: primary calendar of the user + + If user's email or "primary" is specified, then primary calendar of the user is used. + You don't need to specify this parameter in this case as it is a default behaviour. + + To use a different calendar you need to specify its id. + Go to calendar's `settings and sharing` -> `Integrate calendar` -> `Calendar ID`. + """ + super().__init__(*args, **kwargs) + self.default_calendar = default_calendar + + @staticmethod + def _list_paginated( + request_method: Callable, + serializer_cls: Type = None, + **kwargs + ): + page_token = None + while True: + response_json = request_method( + **kwargs, + pageToken=page_token + ).execute() + for item_json in response_json['items']: + if serializer_cls: + yield serializer_cls(item_json).get_object() + else: + yield item_json + page_token = response_json.get('nextPageToken') + if not page_token: + break + + @staticmethod + def _get_resource_id(resource: Union[Resource, str]): + """If `resource` is `Resource` returns its id. + If `resource` is string, returns `resource` itself. + + :raises: + ValueError: if `resource` is `Resource` object that doesn't have id + TypeError: if `resource` is neither `Resource` nor `str` + """ + if isinstance(resource, Resource): + if resource.id is None: + raise ValueError("Resource has to have id to be updated, moved or deleted.") + return resource.id + elif isinstance(resource, str): + return resource + else: + raise TypeError('"resource" object must be Resource or str, not {!r}'.format( + resource.__class__.__name__ + )) diff --git a/google-calendar-simple-api/build/lib/gcsa/_services/calendar_lists_service.py b/google-calendar-simple-api/build/lib/gcsa/_services/calendar_lists_service.py new file mode 100644 index 0000000000000000000000000000000000000000..b48bef6bfebe683b78bd02f65a42cceaa120f3da --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/_services/calendar_lists_service.py @@ -0,0 +1,123 @@ +from typing import Iterable, Union + +from gcsa._services.base_service import BaseService +from gcsa.calendar import CalendarListEntry, Calendar +from gcsa.serializers.calendar_serializer import CalendarListEntrySerializer + + +class CalendarListService(BaseService): + """Calendar list management methods of the `GoogleCalendar`""" + + def get_calendar_list( + self, + min_access_role: str = None, + show_deleted: bool = False, + show_hidden: bool = False + ) -> Iterable[CalendarListEntry]: + """Returns the calendars on the user's calendar list. + + :param min_access_role: + The minimum access role for the user in the returned entries. See :py:class:`~gcsa.calendar.AccessRoles` + The default is no restriction. + :param show_deleted: + Whether to include deleted calendar list entries in the result. The default is False. + :param show_hidden: + Whether to show hidden entries. The default is False. + + :return: + Iterable of :py:class:`~gcsa.calendar.CalendarListEntry` objects. + """ + yield from self._list_paginated( + self.service.calendarList().list, + serializer_cls=CalendarListEntrySerializer, + minAccessRole=min_access_role, + showDeleted=show_deleted, + showHidden=show_hidden, + ) + + def get_calendar_list_entry( + self, + calendar_id: str = None + ) -> CalendarListEntry: + """Returns a calendar with the corresponding calendar_id from the user's calendar list. + + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar` + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + + :return: + The corresponding :py:class:`~gcsa.calendar.CalendarListEntry` object. + """ + calendar_id = calendar_id or self.default_calendar + calendar_resource = self.service.calendarList().get(calendarId=calendar_id).execute() + return CalendarListEntrySerializer.to_object(calendar_resource) + + def add_calendar_list_entry( + self, + calendar: CalendarListEntry, + color_rgb_format: bool = None + ) -> CalendarListEntry: + """Adds an existing calendar into the user's calendar list. + + :param calendar: + :py:class:`~gcsa.calendar.CalendarListEntry` object. + :param color_rgb_format: + Whether to use the `foreground_color` and `background_color` fields to write the calendar colors (RGB). + If this feature is used, the index-based `color_id` field will be set to the best matching option + automatically. The default is True if `foreground_color` or `background_color` is set, False otherwise. + + :return: + Created `CalendarListEntry` object with id. + """ + if color_rgb_format is None: + color_rgb_format = (calendar.foreground_color is not None) or (calendar.background_color is not None) + + body = CalendarListEntrySerializer.to_json(calendar) + calendar_json = self.service.calendarList().insert( + body=body, + colorRgbFormat=color_rgb_format + ).execute() + return CalendarListEntrySerializer.to_object(calendar_json) + + def update_calendar_list_entry( + self, + calendar: CalendarListEntry, + color_rgb_format: bool = None + ) -> CalendarListEntry: + """Updates an existing calendar on the user's calendar list. + + :param calendar: + :py:class:`~gcsa.calendar.Calendar` object with set `calendar_id` + :param color_rgb_format: + Whether to use the `foreground_color` and `background_color` fields to write the calendar colors (RGB). + If this feature is used, the index-based color_id field will be set to the best matching option + automatically. The default is True if `foreground_color` or `background_color` is set, False otherwise. + + :return: + Updated calendar list entry object + """ + calendar_id = self._get_resource_id(calendar) + if color_rgb_format is None: + color_rgb_format = calendar.foreground_color is not None or calendar.background_color is not None + + body = CalendarListEntrySerializer.to_json(calendar) + calendar_json = self.service.calendarList().update( + calendarId=calendar_id, + body=body, + colorRgbFormat=color_rgb_format + ).execute() + return CalendarListEntrySerializer.to_object(calendar_json) + + def delete_calendar_list_entry( + self, + calendar: Union[Calendar, CalendarListEntry, str] + ): + """Removes a calendar from the user's calendar list. + + :param calendar: + Calendar's ID or :py:class:`~gcsa.calendar.Calendar`/:py:class:`~gcsa.calendar.CalendarListEntry` object + with the set `calendar_id`. + """ + calendar_id = self._get_resource_id(calendar) + self.service.calendarList().delete(calendarId=calendar_id).execute() diff --git a/google-calendar-simple-api/build/lib/gcsa/_services/calendars_service.py b/google-calendar-simple-api/build/lib/gcsa/_services/calendars_service.py new file mode 100644 index 0000000000000000000000000000000000000000..c50c39b9bdd4534c010476208ffd382fd58e6c82 --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/_services/calendars_service.py @@ -0,0 +1,102 @@ +from typing import Union + +from gcsa._services.base_service import BaseService +from gcsa.calendar import Calendar, CalendarListEntry +from gcsa.serializers.calendar_serializer import CalendarSerializer + + +class CalendarsService(BaseService): + """Calendars management methods of the `GoogleCalendar`""" + + def get_calendar( + self, + calendar_id: str = None + ) -> Calendar: + """Returns the calendar with the corresponding calendar_id. + + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + + :return: + The corresponding :py:class:`~gcsa.calendar.Calendar` object. + """ + calendar_id = calendar_id or self.default_calendar + calendar_resource = self.service.calendars().get( + calendarId=calendar_id + ).execute() + return CalendarSerializer.to_object(calendar_resource) + + def add_calendar( + self, + calendar: Calendar + ): + """Creates a secondary calendar. + + :param calendar: + Calendar object. + :return: + Created calendar object with ID. + """ + body = CalendarSerializer.to_json(calendar) + calendar_json = self.service.calendars().insert( + body=body + ).execute() + return CalendarSerializer.to_object(calendar_json) + + def update_calendar( + self, + calendar: Calendar + ): + """Updates metadata for a calendar. + + :param calendar: + Calendar object with set `calendar_id` + + :return: + Updated calendar object + """ + calendar_id = self._get_resource_id(calendar) + body = CalendarSerializer.to_json(calendar) + calendar_json = self.service.calendars().update( + calendarId=calendar_id, + body=body + ).execute() + return CalendarSerializer.to_object(calendar_json) + + def delete_calendar( + self, + calendar: Union[Calendar, CalendarListEntry, str] + ): + """Deletes a secondary calendar. + + Use :py:meth:`~gcsa.google_calendar.GoogleCalendar.clear_calendar` for clearing all events on primary calendars. + + :param calendar: + Calendar's ID or :py:class:`~gcsa.calendar.Calendar` object with set `calendar_id`. + """ + calendar_id = self._get_resource_id(calendar) + self.service.calendars().delete(calendarId=calendar_id).execute() + + def clear_calendar(self): + """Clears a **primary** calendar. + This operation deletes all events associated with the **primary** calendar of an account. + + Currently, there is no way to clear a secondary calendar. + You can use :py:meth:`~gcsa.google_calendar.GoogleCalendar.delete_event` method with the secondary calendar's ID + to delete events from a secondary calendar. + """ + self.service.calendars().clear(calendarId='primary').execute() + + def clear(self): + """Kept for back-compatibility. Use :py:meth:`~gcsa.google_calendar.GoogleCalendar.clear_calendar` instead. + + Clears a **primary** calendar. + This operation deletes all events associated with the **primary** calendar of an account. + + Currently, there is no way to clear a secondary calendar. + You can use :py:meth:`~gcsa.google_calendar.GoogleCalendar.delete_event` method with the secondary calendar's ID + to delete events from a secondary calendar. + """ + self.clear_calendar() diff --git a/google-calendar-simple-api/build/lib/gcsa/_services/colors_service.py b/google-calendar-simple-api/build/lib/gcsa/_services/colors_service.py new file mode 100644 index 0000000000000000000000000000000000000000..480513be8fd9cd52488ab881254889b8df9a3f32 --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/_services/colors_service.py @@ -0,0 +1,15 @@ +from gcsa._services.base_service import BaseService + + +class ColorsService(BaseService): + """Colors management methods of the `GoogleCalendar`""" + + def list_event_colors(self) -> dict: + """A global palette of event colors, mapping from the color ID to its definition. + An :py:class:`~gcsa.event.Event` may refer to one of these color IDs in its color_id field.""" + return self.service.colors().get().execute()['event'] + + def list_calendar_colors(self) -> dict: + """A global palette of calendar colors, mapping from the color ID to its definition. + :py:class:`~gcsa.calendar.CalendarListEntry` resource refers to one of these color IDs in its color_id field.""" + return self.service.colors().get().execute()['calendar'] diff --git a/google-calendar-simple-api/build/lib/gcsa/_services/events_service.py b/google-calendar-simple-api/build/lib/gcsa/_services/events_service.py new file mode 100644 index 0000000000000000000000000000000000000000..af2bf49a18cce52da0bc3df793878e9c89b4a02d --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/_services/events_service.py @@ -0,0 +1,427 @@ +from datetime import date, datetime +from typing import Union, Iterator, Iterable, Callable + +from beautiful_date import BeautifulDate +from dateutil.relativedelta import relativedelta +from tzlocal import get_localzone_name + +from gcsa._services.base_service import BaseService +from gcsa.event import Event +from gcsa.serializers.event_serializer import EventSerializer +from gcsa.util.date_time_util import to_localized_iso + + +class SendUpdatesMode: + """Possible values of the mode for sending updates or invitations to attendees. + + * ALL - Send updates to all participants. This is the default value. + * EXTERNAL_ONLY - Send updates only to attendees not using google calendar. + * NONE - Do not send updates. + """ + + ALL = "all" + EXTERNAL_ONLY = "externalOnly" + NONE = "none" + + +class EventsService(BaseService): + """Event management methods of the `GoogleCalendar`""" + + def _list_events( + self, + request_method: Callable, + time_min: Union[date, datetime, BeautifulDate], + time_max: Union[date, datetime, BeautifulDate], + timezone: str, + calendar_id: str, + **kwargs + ) -> Iterable[Event]: + """Lists paginated events received from request_method.""" + + time_min = time_min or datetime.now() + time_max = time_max or time_min + relativedelta(years=1) + + time_min = to_localized_iso(time_min, timezone) + time_max = to_localized_iso(time_max, timezone) + + yield from self._list_paginated( + request_method, + serializer_cls=EventSerializer, + calendarId=calendar_id, + timeMin=time_min, + timeMax=time_max, + **kwargs + ) + + def get_events( + self, + time_min: Union[date, datetime, BeautifulDate] = None, + time_max: Union[date, datetime, BeautifulDate] = None, + order_by: str = None, + timezone: str = get_localzone_name(), + single_events: bool = False, + query: str = None, + calendar_id: str = None, + **kwargs + ) -> Iterable[Event]: + """Lists events. + + :param time_min: + Staring date/datetime + :param time_max: + Ending date/datetime + :param order_by: + Order of the events. Possible values: "startTime", "updated". Default is unspecified stable order. + :param timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". By default, + the computers local timezone is used if it is configured. UTC is used otherwise. + :param single_events: + Whether to expand recurring events into instances and only return single one-off events and + instances of recurring events, but not the underlying recurring events themselves. + :param query: + Free text search terms to find events that match these terms in any field, except for + extended properties. + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + :param kwargs: + Additional API parameters. + See https://developers.google.com/calendar/v3/reference/events/list#optional-parameters + + :return: + Iterable of `Event` objects + """ + calendar_id = calendar_id or self.default_calendar + if not single_events and order_by == 'startTime': + raise ValueError( + '"startTime" ordering is only available when querying single events, i.e. single_events=True' + ) + yield from self._list_events( + self.service.events().list, + time_min=time_min, + time_max=time_max, + timezone=timezone, + calendar_id=calendar_id, + **{ + 'singleEvents': single_events, + 'orderBy': order_by, + 'q': query, + **kwargs + } + ) + + def get_instances( + self, + recurring_event: Union[Event, str], + time_min: Union[date, datetime, BeautifulDate] = None, + time_max: Union[date, datetime, BeautifulDate] = None, + timezone: str = get_localzone_name(), + calendar_id: str = None, + **kwargs + ) -> Iterable[Event]: + """Lists instances of recurring event + + :param recurring_event: + Recurring event or instance of recurring event (`Event` object) or id of the recurring event + :param time_min: + Staring date/datetime + :param time_max: + Ending date/datetime + :param timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". By default, + the computers local timezone is used if it is configured. UTC is used otherwise. + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + :param kwargs: + Additional API parameters. + See https://developers.google.com/calendar/v3/reference/events/instances#optional-parameters + + :return: + Iterable of event objects + """ + calendar_id = calendar_id or self.default_calendar + try: + event_id = self._get_resource_id(recurring_event) + except ValueError: + raise ValueError("Recurring event has to have id to retrieve its instances.") + + yield from self._list_events( + self.service.events().instances, + time_min=time_min, + time_max=time_max, + timezone=timezone, + calendar_id=calendar_id, + **{ + 'eventId': event_id, + **kwargs + } + ) + + def __iter__(self) -> Iterator[Event]: + return iter(self.get_events()) + + def __getitem__(self, r): + if isinstance(r, slice): + time_min, time_max, order_by = r.start or None, r.stop or None, r.step or None + elif isinstance(r, (date, datetime)): + time_min, time_max, order_by = r, None, None + else: + raise NotImplementedError + + if ( + (time_min and not isinstance(time_min, (date, datetime))) + or (time_max and not isinstance(time_max, (date, datetime))) + or (order_by and (not isinstance(order_by, str) or order_by not in self._LIST_ORDERS)) + ): + raise ValueError('Calendar indexing is in the following format: time_min[:time_max[:order_by]],' + ' where time_min and time_max are date/datetime objects' + ' and order_by is None or one of "startTime" or "updated" strings.') + + return self.get_events(time_min, time_max, order_by=order_by, single_events=(order_by == "startTime")) + + def get_event( + self, + event_id: str, + calendar_id: str = None, + **kwargs + ) -> Event: + """Returns the event with the corresponding event_id. + + :param event_id: + The unique event ID. + :param kwargs: + Additional API parameters. + See https://developers.google.com/calendar/v3/reference/events/get#optional-parameters + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + + :return: + The corresponding event object. + """ + calendar_id = calendar_id or self.default_calendar + event_resource = self.service.events().get( + calendarId=calendar_id, + eventId=event_id, + **kwargs + ).execute() + return EventSerializer.to_object(event_resource) + + def add_event( + self, + event: Event, + send_updates: str = SendUpdatesMode.NONE, + calendar_id: str = None, + **kwargs + ) -> Event: + """Creates event in the calendar + + :param event: + Event object. + :param send_updates: + Whether and how to send updates to attendees. See :py:class:`~gcsa.google_calendar.SendUpdatesMode` + Default is "NONE". + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + :param kwargs: + Additional API parameters. + See https://developers.google.com/calendar/v3/reference/events/insert#optional-parameters + + :return: + Created event object with id. + """ + calendar_id = calendar_id or self.default_calendar + body = EventSerializer.to_json(event) + event_json = self.service.events().insert( + calendarId=calendar_id, + body=body, + conferenceDataVersion=1, + sendUpdates=send_updates, + **kwargs + ).execute() + return EventSerializer.to_object(event_json) + + def add_quick_event( + self, + event_string: str, + send_updates: str = SendUpdatesMode.NONE, + calendar_id: str = None, + **kwargs + ) -> Event: + """Creates event in the calendar by string description. + + Example: + Appointment at Somewhere on June 3rd 10am-10:25am + + :param event_string: + String that describes an event + :param send_updates: + Whether and how to send updates to attendees. See :py:class:`~gcsa.google_calendar.SendUpdatesMode` + Default is "NONE". + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + :param kwargs: + Additional API parameters. + See https://developers.google.com/calendar/v3/reference/events/quickAdd#optional-parameters + + :return: + Created event object with id. + """ + calendar_id = calendar_id or self.default_calendar + event_json = self.service.events().quickAdd( + calendarId=calendar_id, + text=event_string, + sendUpdates=send_updates, + **kwargs + ).execute() + return EventSerializer.to_object(event_json) + + def update_event( + self, + event: Event, + send_updates: str = SendUpdatesMode.NONE, + calendar_id: str = None, + **kwargs + ) -> Event: + """Updates existing event in the calendar + + :param event: + Event object with set `event_id`. + :param send_updates: + Whether and how to send updates to attendees. See :py:class:`~gcsa.google_calendar.SendUpdatesMode` + Default is "NONE". + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + :param kwargs: + Additional API parameters. + See https://developers.google.com/calendar/v3/reference/events/update#optional-parameters + + :return: + Updated event object. + """ + calendar_id = calendar_id or self.default_calendar + event_id = self._get_resource_id(event) + body = EventSerializer.to_json(event) + event_json = self.service.events().update( + calendarId=calendar_id, + eventId=event_id, + body=body, + conferenceDataVersion=1, + sendUpdates=send_updates, + **kwargs + ).execute() + return EventSerializer.to_object(event_json) + + def import_event( + self, + event: Event, + calendar_id: str = None, + **kwargs + ) -> Event: + """Imports an event in the calendar + + This operation is used to add a private copy of an existing event to a calendar. + + :param event: + Event object. + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + :param kwargs: + Additional API parameters. + See https://developers.google.com/calendar/v3/reference/events/import#optional-parameters + + :return: + Created event object with id. + """ + calendar_id = calendar_id or self.default_calendar + body = EventSerializer.to_json(event) + event_json = self.service.events().import_( + calendarId=calendar_id, + body=body, + conferenceDataVersion=1, + **kwargs + ).execute() + return EventSerializer.to_object(event_json) + + def move_event( + self, + event: Event, + destination_calendar_id: str, + send_updates: str = SendUpdatesMode.NONE, + source_calendar_id: str = None, + **kwargs + ) -> Event: + """Moves existing event from calendar to another calendar + + :param event: + Event object with set event_id. + :param destination_calendar_id: + ID of the destination calendar. + :param send_updates: + Whether and how to send updates to attendees. See :py:class:`~gcsa.google_calendar.SendUpdatesMode` + Default is "NONE". + :param source_calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + :param kwargs: + Additional API parameters. + See https://developers.google.com/calendar/v3/reference/events/move#optional-parameters + + :return: + Moved event object. + """ + source_calendar_id = source_calendar_id or self.default_calendar + event_id = self._get_resource_id(event) + moved_event_json = self.service.events().move( + calendarId=source_calendar_id, + eventId=event_id, + destination=destination_calendar_id, + sendUpdates=send_updates, + **kwargs + ).execute() + return EventSerializer.to_object(moved_event_json) + + def delete_event( + self, + event: Union[Event, str], + send_updates: str = SendUpdatesMode.NONE, + calendar_id: str = None, + **kwargs + ): + """Deletes an event. + + :param event: + Event's ID or `Event` object with set `event_id`. + :param send_updates: + Whether and how to send updates to attendees. See :py:class:`~gcsa.google_calendar.SendUpdatesMode` + Default is "NONE". + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + :param kwargs: + Additional API parameters. + See https://developers.google.com/calendar/v3/reference/events/delete#optional-parameters + """ + calendar_id = calendar_id or self.default_calendar + event_id = self._get_resource_id(event) + + self.service.events().delete( + calendarId=calendar_id, + eventId=event_id, + sendUpdates=send_updates, + **kwargs + ).execute() diff --git a/google-calendar-simple-api/build/lib/gcsa/_services/free_busy_service.py b/google-calendar-simple-api/build/lib/gcsa/_services/free_busy_service.py new file mode 100644 index 0000000000000000000000000000000000000000..3e906f429cf2dae4730d34a1f1d8821043660175 --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/_services/free_busy_service.py @@ -0,0 +1,87 @@ +from datetime import date, datetime +from typing import Union, List + +from beautiful_date import BeautifulDate +from dateutil.relativedelta import relativedelta +from tzlocal import get_localzone_name + +from gcsa._services.base_service import BaseService +from gcsa.free_busy import FreeBusy, FreeBusyQueryError +from gcsa.serializers.free_busy_serializer import FreeBusySerializer +from gcsa.util.date_time_util import to_localized_iso + + +class FreeBusyService(BaseService): + def get_free_busy( + self, + resource_ids: Union[str, List[str]] = None, + *, + time_min: Union[date, datetime, BeautifulDate] = None, + time_max: Union[date, datetime, BeautifulDate] = None, + timezone: str = get_localzone_name(), + group_expansion_max: int = None, + calendar_expansion_max: int = None, + ignore_errors: bool = False + ) -> FreeBusy: + """Returns free/busy information for a set of calendars and/or groups. + + :param resource_ids: + Identifier or list of identifiers of calendar(s) and/or group(s). + Default is `default_calendar` specified in `GoogleCalendar`. + :param time_min: + The start of the interval for the query. + :param time_max: + The end of the interval for the query. + :param timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". By default, + the computers local timezone is used if it is configured. UTC is used otherwise. + :param group_expansion_max: + Maximal number of calendar identifiers to be provided for a single group. + An error is returned for a group with more members than this value. + Maximum value is 100. + :param calendar_expansion_max: + Maximal number of calendars for which FreeBusy information is to be provided. + Maximum value is 50. + :param ignore_errors: + Whether errors related to calendars and/or groups should be ignored. + If `False` :py:class:`~gcsa.free_busy.FreeBusyQueryError` is raised in case of query related errors. + If `True`, related errors are stored in the resulting :py:class:`~gcsa.free_busy.FreeBusy` object. + Default is `False`. + Note, request related errors (e.x. authentication error) will not be ignored regardless of + the `ignore_errors` value. + + :return: + :py:class:`~gcsa.free_busy.FreeBusy` object. + """ + + time_min = time_min or datetime.now() + time_max = time_max or time_min + relativedelta(weeks=2) + + time_min = to_localized_iso(time_min, timezone) + time_max = to_localized_iso(time_max, timezone) + + if resource_ids is None: + resource_ids = [self.default_calendar] + elif not isinstance(resource_ids, (list, tuple, set)): + resource_ids = [resource_ids] + + body = { + "timeMin": time_min, + "timeMax": time_max, + "timeZone": timezone, + "groupExpansionMax": group_expansion_max, + "calendarExpansionMax": calendar_expansion_max, + "items": [ + { + "id": r_id + } for r_id in resource_ids + ] + } + + free_busy_json = self.service.freebusy().query(body=body).execute() + free_busy = FreeBusySerializer.to_object(free_busy_json) + if not ignore_errors and (free_busy.groups_errors or free_busy.calendars_errors): + raise FreeBusyQueryError(groups_errors=free_busy.groups_errors, + calendars_errors=free_busy.calendars_errors) + + return free_busy diff --git a/google-calendar-simple-api/build/lib/gcsa/_services/settings_service.py b/google-calendar-simple-api/build/lib/gcsa/_services/settings_service.py new file mode 100644 index 0000000000000000000000000000000000000000..91b6cee59ff8e322d6396984c02bc55dcf15893f --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/_services/settings_service.py @@ -0,0 +1,13 @@ +from gcsa._services.base_service import BaseService +from gcsa.serializers.settings_serializer import SettingsSerializer +from gcsa.settings import Settings + + +class SettingsService(BaseService): + """Settings management methods of the `GoogleCalendar`""" + + def get_settings(self) -> Settings: + """Returns user settings for the authenticated user.""" + settings_list = list(self._list_paginated(self.service.settings().list)) + settings_json = {s['id']: s['value'] for s in settings_list} + return SettingsSerializer.to_object(settings_json) diff --git a/google-calendar-simple-api/build/lib/gcsa/acl.py b/google-calendar-simple-api/build/lib/gcsa/acl.py new file mode 100644 index 0000000000000000000000000000000000000000..d9fb418c48fa0bc8730343f565f6acd3dc8f781e --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/acl.py @@ -0,0 +1,70 @@ +from gcsa._resource import Resource + + +class ACLRole: + """ + * `NONE` - Provides no access. + * `FREE_BUSY_READER` - Provides read access to free/busy information. + * `READER` - Provides read access to the calendar. Private events will appear to users with reader access, but event + details will be hidden. + * `WRITER` - Provides read and write access to the calendar. Private events will appear to users with writer access, + and event details will be visible. + * `OWNER` - Provides ownership of the calendar. This role has all of the permissions of the writer role with + the additional ability to see and manipulate ACLs. + """ + + NONE = "none" + FREE_BUSY_READER = "freeBusyReader" + READER = "reader" + WRITER = "writer" + OWNER = "owner" + + +class ACLScopeType: + """ + * `DEFAULT` - The public scope. + * `USER` - Limits the scope to a single user. + * `GROUP` - Limits the scope to a group. + * `DOMAIN` - Limits the scope to a domain. + """ + + DEFAULT = "default" + USER = "user" + GROUP = "group" + DOMAIN = "domain" + + +class AccessControlRule(Resource): + def __init__( + self, + *, + role: str, + scope_type: str, + acl_id: str = None, + scope_value: str = None + ): + """ + :param role: + The role assigned to the scope. See :py:class:`~gcsa.acl.ACLRole`. + :param scope_type: + The type of the scope. See :py:class:`~gcsa.acl.ACLScopeType`. + :param acl_id: + Identifier of the Access Control List (ACL) rule. + :param scope_value: + The email address of a user or group, or the name of a domain, depending on the scope type. + Omitted for type "default". + """ + self.acl_id = acl_id + self.role = role + self.scope_type = scope_type + self.scope_value = scope_value + + @property + def id(self): + return self.acl_id + + def __str__(self): + return '{} - {}'.format(self.scope_value, self.role) + + def __repr__(self): + return ''.format(self.__str__()) diff --git a/google-calendar-simple-api/build/lib/gcsa/attachment.py b/google-calendar-simple-api/build/lib/gcsa/attachment.py new file mode 100644 index 0000000000000000000000000000000000000000..ce944712ffd210d1d7f594384bb0c0b15970a182 --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/attachment.py @@ -0,0 +1,72 @@ +class Attachment: + _SUPPORTED_MIME_TYPES = { + "application/vnd.google-apps.audio", + "application/vnd.google-apps.document", # Google Docs + "application/vnd.google-apps.drawing", # Google Drawing + "application/vnd.google-apps.file", # Google Drive file + "application/vnd.google-apps.folder", # Google Drive folder + "application/vnd.google-apps.form", # Google Forms + "application/vnd.google-apps.fusiontable", # Google Fusion Tables + "application/vnd.google-apps.map", # Google My Maps + "application/vnd.google-apps.photo", + "application/vnd.google-apps.presentation", # Google Slides + "application/vnd.google-apps.script", # Google Apps Scripts + "application/vnd.google-apps.site", # Google Sites + "application/vnd.google-apps.spreadsheet", # Google Sheets + "application/vnd.google-apps.unknown", + "application/vnd.google-apps.video", + "application/vnd.google-apps.drive-sdk" # 3rd party shortcut + } + + def __init__( + self, + file_url: str, + title: str = None, + mime_type: str = None, + _icon_link: str = None, + _file_id: str = None + ): + """File attachment for the event. + + Currently only Google Drive attachments are supported. + + :param file_url: + A link for opening the file in a relevant Google editor or viewer. + :param title: + Attachment title + :param mime_type: + Internet media type (MIME type) of the attachment. See `available MIME types`_ + :param _icon_link: + URL link to the attachment's icon (read only) + :param _file_id: + Id of the attached file (read only) + + .. note: "read only" means that Attachment has given property only + when received from the existing event in the calendar. + + .. _`available MIME types`: https://developers.google.com/drive/api/v3/mime-types + """ + + self.unsupported_mime_type = mime_type not in Attachment._SUPPORTED_MIME_TYPES + + self.file_url = file_url + self.title = title + self.mime_type = mime_type + self.icon_link = _icon_link + self.file_id = _file_id + + def __eq__(self, other): + return ( + isinstance(other, Attachment) + and self.file_url == other.file_url + and self.title == other.title + and self.mime_type == other.mime_type + and self.icon_link == other.icon_link + and self.file_id == other.file_id + ) + + def __str__(self): + return "'{}' - '{}'".format(self.title, self.file_url) + + def __repr__(self): + return ''.format(self.__str__()) diff --git a/google-calendar-simple-api/build/lib/gcsa/attendee.py b/google-calendar-simple-api/build/lib/gcsa/attendee.py new file mode 100644 index 0000000000000000000000000000000000000000..f4d0a60c7c98243d36dc0a9e3648651c24d6c300 --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/attendee.py @@ -0,0 +1,79 @@ +from .person import Person + + +class ResponseStatus: + """Possible values for attendee's response status + + * NEEDS_ACTION - The attendee has not responded to the invitation. + * DECLINED - The attendee has declined the invitation. + * TENTATIVE - The attendee has tentatively accepted the invitation. + * ACCEPTED - The attendee has accepted the invitation. + """ + NEEDS_ACTION = "needsAction" + DECLINED = "declined" + TENTATIVE = "tentative" + ACCEPTED = "accepted" + + +class Attendee(Person): + def __init__( + self, + email: str, + display_name: str = None, + comment: str = None, + optional: bool = None, + is_resource: bool = None, + additional_guests: int = None, + _id: str = None, + _is_self: bool = None, + _response_status: str = None + ): + """Represents attendee of the event. + + :param email: + The attendee's email address, if available. + :param display_name: + The attendee's name, if available + :param comment: + The attendee's response comment + :param optional: + Whether this is an optional attendee. The default is False. + :param is_resource: + Whether the attendee is a resource. + Can only be set when the attendee is added to the event + for the first time. Subsequent modifications are ignored. + The default is False. + :param additional_guests: + Number of additional guests. The default is 0. + :param _id: + The attendee's Profile ID, if available. + It corresponds to the id field in the People collection of the Google+ API + :param _is_self: + Whether this entry represents the calendar on which this copy of the event appears. + The default is False (set by Google's API). + :param _response_status: + The attendee's response status. See :py:class:`~gcsa.attendee.ResponseStatus` + """ + super().__init__(email=email, display_name=display_name, _id=_id, _is_self=_is_self) + self.comment = comment + self.optional = optional + self.is_resource = is_resource + self.additional_guests = additional_guests + self.response_status = _response_status + + def __eq__(self, other): + return ( + isinstance(other, Attendee) + and super().__eq__(other) + and self.comment == other.comment + and self.optional == other.optional + and self.is_resource == other.is_resource + and self.additional_guests == other.additional_guests + and self.response_status == other.response_status + ) + + def __str__(self): + return "'{}' - response: '{}'".format(self.email, self.response_status) + + def __repr__(self): + return ''.format(self.__str__()) diff --git a/google-calendar-simple-api/build/lib/gcsa/calendar.py b/google-calendar-simple-api/build/lib/gcsa/calendar.py new file mode 100644 index 0000000000000000000000000000000000000000..1cf848e1444a6bf589dac7592e6b2a5f4d64eeba --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/calendar.py @@ -0,0 +1,267 @@ +from typing import List + +from tzlocal import get_localzone_name + +from ._resource import Resource +from .reminders import Reminder + + +class NotificationType: + """ + * `EVENT_CREATION` - Notification sent when a new event is put on the calendar. + * `EVENT_CHANGE` - Notification sent when an event is changed. + * `EVENT_CANCELLATION` - Notification sent when an event is cancelled. + * `EVENT_RESPONSE` - Notification sent when an attendee responds to the event invitation. + * `AGENDA` - An agenda with the events of the day (sent out in the morning). + """ + + EVENT_CREATION = "eventCreation" + EVENT_CHANGE = "eventChange" + EVENT_CANCELLATION = "eventCancellation" + EVENT_RESPONSE = "eventResponse" + AGENDA = "agenda" + + +class AccessRoles: + """ + * `FREE_BUSY_READER` - Provides read access to free/busy information. + * `READER` - Provides read access to the calendar. + Private events will appear to users with reader access, but event details will be hidden. + * `WRITER` - Provides read and write access to the calendar. + Private events will appear to users with writer access, and event details will be visible. + * `OWNER` - Provides ownership of the calendar. + This role has all of the permissions of the writer role with the additional ability to see and manipulate ACLs. + """ + + FREE_BUSY_READER = "freeBusyReader" + READER = "reader" + WRITER = "writer" + OWNER = "owner" + + +class Calendar(Resource): + def __init__( + self, + summary: str, + *, + calendar_id: str = None, + description: str = None, + location: str = None, + timezone: str = get_localzone_name(), + allowed_conference_solution_types: List[str] = None + ): + """ + :param summary: + Title of the calendar. + :param calendar_id: + Identifier of the calendar. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + :param description: + Description of the calendar. + :param location: + Geographic location of the calendar as free-form text. + :param timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". By default, + the computers local timezone is used if it is configured. UTC is used otherwise. + :param allowed_conference_solution_types: + The types of conference solutions that are supported for this calendar. + See :py:class:`~gcsa.conference.SolutionType` + """ + self.summary = summary + self.calendar_id = calendar_id + self.description = description + self.location = location + self.timezone = timezone + self.allowed_conference_solution_types = allowed_conference_solution_types + + @property + def id(self): + return self.calendar_id + + def to_calendar_list_entry( + self, + summary_override: str = None, + color_id: str = None, + background_color: str = None, + foreground_color: str = None, + hidden: bool = False, + selected: bool = False, + default_reminders: List[Reminder] = None, + notification_types: List[str] = None, + ) -> 'CalendarListEntry': + """Converts :py:class:`~gcsa.calendar.Calendar` to :py:class:`~gcsa.calendar.CalendarListEntry` + that can be added to the calendar list. + + :py:class:`~gcsa.calendar.Calendar` has to have `calendar_id` set + to be converted to :py:class:`~gcsa.calendar.CalendarListEntry` + + :param summary_override: + The summary that the authenticated user has set for this calendar. + :param color_id: + The color of the calendar. This is an ID referring to an entry in the calendar section of the colors' + definition (See :py:meth:`~gcsa.google_calendar.GoogleCalendar.list_calendar_colors`). + This property is superseded by the `background_color` and `foreground_color` properties + and can be ignored when using these properties. + :param background_color: + The main color of the calendar in the hexadecimal format "#0088aa". + This property supersedes the index-based color_id property. + :param foreground_color: + The foreground color of the calendar in the hexadecimal format "#ffffff". + This property supersedes the index-based color_id property. + :param hidden: + Whether the calendar has been hidden from the list. + :param selected: + Whether the calendar content shows up in the calendar UI. The default is False. + :param default_reminders: + The default reminders that the authenticated user has for this calendar. :py:mod:`~gcsa.reminders` + :param notification_types: + The list of notification types set for this calendar. :py:class:`~gcsa:calendar:NotificationType` + + :return: + :py:class:`~gcsa.calendar.CalendarListEntry` object that can be added to the calendar list. + """ + if self.id is None: + raise ValueError('Calendar has to have `calendar_id` set to be converted to CalendarListEntry') + + return CalendarListEntry( + _summary=self.summary, + calendar_id=self.calendar_id, + _description=self.description, + _location=self.location, + _timezone=self.timezone, + _allowed_conference_solution_types=self.allowed_conference_solution_types, + + summary_override=summary_override, + color_id=color_id, + background_color=background_color, + foreground_color=foreground_color, + hidden=hidden, + selected=selected, + default_reminders=default_reminders, + notification_types=notification_types, + ) + + def __str__(self): + return '{} - {}'.format(self.summary, self.description) + + def __repr__(self): + return ''.format(self.__str__()) + + def __eq__(self, other): + if not isinstance(other, Calendar): + return NotImplemented + elif self is other: + return True + else: + return super().__eq__(other) + + +class CalendarListEntry(Calendar): + def __init__( + self, + calendar_id: str, + *, + summary_override: str = None, + color_id: str = None, + background_color: str = None, + foreground_color: str = None, + hidden: bool = False, + selected: bool = False, + default_reminders: List[Reminder] = None, + notification_types: List[str] = None, + _summary: str = None, + _description: str = None, + _location: str = None, + _timezone: str = None, + _allowed_conference_solution_types: List[str] = None, + _access_role: str = None, + _primary: bool = False, + _deleted: bool = False + ): + """ + :param calendar_id: + Identifier of the calendar. + :param summary_override: + The summary that the authenticated user has set for this calendar. + :param color_id: + The color of the calendar. This is an ID referring to an entry in the calendar section of the colors' + definition (See :py:meth:`~gcsa.google_calendar.GoogleCalendar.list_calendar_colors`). + This property is superseded by the `background_color` and `foreground_color` properties + and can be ignored when using these properties. + :param background_color: + The main color of the calendar in the hexadecimal format "#0088aa". + This property supersedes the index-based color_id property. + :param foreground_color: + The foreground color of the calendar in the hexadecimal format "#ffffff". + This property supersedes the index-based color_id property. + :param hidden: + Whether the calendar has been hidden from the list. + :param selected: + Whether the calendar content shows up in the calendar UI. The default is False. + :param default_reminders: + The default reminders that the authenticated user has for this calendar. :py:mod:`~gcsa.reminders` + :param notification_types: + The list of notification types set for this calendar. :py:class:`~gcsa:calendar:NotificationType` + :param _summary: + Title of the calendar. Read-only. + :param _description: + Description of the calendar. Read-only. + :param _location: + Geographic location of the calendar as free-form text. Read-only. + :param _timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". Read-only. + :param _allowed_conference_solution_types: + The types of conference solutions that are supported for this calendar. Read-only. + See :py:class:`~gcsa.conference.SolutionType` + :param _access_role: + The effective access role that the authenticated user has on the calendar. Read-only. + See :py:class:`~gcsa.calendar.AccessRoles` + :param _primary: + Whether the calendar is the primary calendar of the authenticated user. Read-only. + :param _deleted: + Whether this calendar list entry has been deleted from the calendar list. Read-only. + """ + super().__init__( + summary=_summary, + calendar_id=calendar_id, + description=_description, + location=_location, + timezone=_timezone, + allowed_conference_solution_types=_allowed_conference_solution_types + ) + self.summary_override = summary_override + self._color_id = color_id + self.background_color = background_color + self.foreground_color = foreground_color + self.hidden = hidden + self.selected = selected + self.default_reminders = default_reminders + self.notification_types = notification_types + self.access_role = _access_role + self.primary = _primary + self.deleted = _deleted + + @property + def color_id(self): + return self._color_id + + @color_id.setter + def color_id(self, color_id): + """Sets the color_id and resets background_color and foreground_color.""" + self._color_id = color_id + self.background_color = None + self.foreground_color = None + + def __str__(self): + return '{} - ({})'.format(self.summary_override, self.summary) + + def __repr__(self): + return ''.format(self.__str__()) + + def __eq__(self, other): + if not isinstance(other, CalendarListEntry): + return NotImplemented + elif self is other: + return True + else: + return super().__eq__(other) diff --git a/google-calendar-simple-api/build/lib/gcsa/conference.py b/google-calendar-simple-api/build/lib/gcsa/conference.py new file mode 100644 index 0000000000000000000000000000000000000000..1af1d098ba47c64e36210533686f19435b4e0826 --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/conference.py @@ -0,0 +1,416 @@ +from typing import Union, List +from uuid import uuid4 + + +class SolutionType: + """ + * HANGOUT - for Hangouts for consumers (hangouts.google.com) + * NAMED_HANGOUT - for classic Hangouts for Google Workspace users (hangouts.google.com) + * HANGOUTS_MEET - for Google Meet (meet.google.com) + * ADD_ON - for 3P conference providers + """ + + HANGOUT = 'eventHangout' + NAMED_HANGOUT = 'eventNamedHangout' + HANGOUTS_MEET = 'hangoutsMeet' + ADD_ON = 'addOn' + + +class _BaseConferenceSolution: + """General conference-related information.""" + + def __init__( + self, + conference_id: str = None, + signature: str = None, + notes: str = None, + _status: str = 'success' + ): + """ + :param conference_id: + The ID of the conference. Optional. + Can be used by developers to keep track of conferences, should not be displayed to users. + + Values for solution types (see :py:class:`~gcsa.conference.SolutionType`): + + * HANGOUT: unset + * NAMED_HANGOUT: the name of the Hangout + * HANGOUTS_MEET: the 10-letter meeting code, for example "aaa-bbbb-ccc" + * ADD_ON: defined by 3P conference provider + + :param signature: + The signature of the conference data. + Generated on server side. Must be preserved while copying the conference data between events, + otherwise the conference data will not be copied. + None for a conference with a failed create request. + Optional for a conference with a pending create request. + :param notes: + String of additional notes (such as instructions from the domain administrator, legal notices) + to display to the user. Can contain HTML. The maximum length is 2048 characters + + :param _status: + The current status of the conference create request. Should not be set by developer. + + The possible values are: + + * "pending": the conference create request is still being processed. + * "failure": the conference create request failed, there are no entry points. + * "success": the conference create request succeeded, the entry points are populated. + In this case `ConferenceSolution` with created entry points + is stored in the event's `conference_data`. And `ConferenceSolutionCreateRequest` is omitted. + + Create requests are asynchronous. Check ``status`` field of event's ``conference_solution`` to find it's + status. If the status is ``"success"``, ``conference_solution`` will contain a + :py:class:`~gcsa.conference.ConferenceSolution` object and you'll be able to access it's field (like + ``entry_points``). Otherwise (if ``status`` is ``""pending"`` or ``"failure"``), ``conference_solution`` + will contain a :py:class:`~gcsa.conference.ConferenceSolutionCreateRequest` object. + + """ + if notes and len(notes) > 2048: + raise ValueError('Maximum notes length is 2048 characters.') + + self.conference_id = conference_id + self.signature = signature + self.notes = notes + self.status = _status + + def __eq__(self, other): + if not isinstance(other, _BaseConferenceSolution): + return NotImplemented + elif self is other: + return True + else: + return ( + self.conference_id == other.conference_id + and self.signature == other.signature + and self.notes == other.notes + ) + + +class EntryPoint: + """Information about individual conference entry points, such as URLs or phone numbers.""" + + VIDEO = 'video' + PHONE = 'phone' + SIP = 'sip' + MORE = 'more' + + ENTRY_POINT_TYPES = (VIDEO, PHONE, SIP, MORE) + + def __init__( + self, + entry_point_type: str, + uri: str = None, + label: str = None, + pin: str = None, + access_code: str = None, + meeting_code: str = None, + passcode: str = None, + password: str = None + ): + """ + When creating new conference data, populate only the subset of `meeting_code`, `access_code`, `passcode`, + `password`, and `pin` fields that match the terminology that the conference provider uses. + + Only the populated fields should be displayed. + + :param entry_point_type: + The type of the conference entry point. + + Possible values are: + + * VIDEO - joining a conference over HTTP. + A conference can have zero or one `VIDEO` entry point. + * PHONE - joining a conference by dialing a phone number. + A conference can have zero or more `PHONE` entry points. + * SIP - joining a conference over SIP. + A conference can have zero or one `SIP` entry point. + * MORE - further conference joining instructions, for example additional phone numbers. + A conference can have zero or one `MORE` entry point. + A conference with only a `MORE` entry point is not a valid conference. + + :param uri: + The URI of the entry point. The maximum length is 1300 characters. + Format: + + * for `VIDEO`, http: or https: schema is required. + * for `PHONE`, tel: schema is required. + The URI should include the entire dial sequence (e.g., tel:+12345678900,,,123456789;1234). + * for `SIP`, sip: schema is required, e.g., sip:12345678@myprovider.com. + * for `MORE`, http: or https: schema is required. + + :param label: + The label for the URI. + Visible to end users. Not localized. The maximum length is 512 characters. + + Examples: + + * for `VIDEO`: meet.google.com/aaa-bbbb-ccc + * for `PHONE`: +1 123 268 2601 + * for `SIP`: 12345678@altostrat.com + * for `MORE`: should not be filled + + :param pin: + The PIN to access the conference. The maximum length is 128 characters. + :param access_code: + The access code to access the conference. The maximum length is 128 characters. Optional. + :param meeting_code: + The meeting code to access the conference. The maximum length is 128 characters. + :param passcode: + The passcode to access the conference. The maximum length is 128 characters. + :param password: + The password to access the conference. The maximum length is 128 characters. + """ + + if entry_point_type and entry_point_type not in self.ENTRY_POINT_TYPES: + raise ValueError('"entry_point" must be one of {}. {} was provided.'.format( + ', '.join(self.ENTRY_POINT_TYPES), + entry_point_type + )) + if label and len(label) > 512: + raise ValueError('Maximum label length is 512 characters.') + if pin and len(pin) > 128: + raise ValueError('Maximum pin length is 128 characters.') + if access_code and len(access_code) > 128: + raise ValueError('Maximum access_code length is 128 characters.') + if meeting_code and len(meeting_code) > 128: + raise ValueError('Maximum meeting_code length is 128 characters.') + if passcode and len(passcode) > 128: + raise ValueError('Maximum passcode length is 128 characters.') + if password and len(password) > 128: + raise ValueError('Maximum password length is 128 characters.') + + self.entry_point_type = entry_point_type + self.uri = uri + self.label = label + self.pin = pin + self.access_code = access_code + self.meeting_code = meeting_code + self.passcode = passcode + self.password = password + + def __eq__(self, other): + if not isinstance(other, EntryPoint): + return NotImplemented + elif self is other: + return True + else: + return ( + self.entry_point_type == other.entry_point_type + and self.uri == other.uri + and self.label == other.label + and self.pin == other.pin + and self.access_code == other.access_code + and self.meeting_code == other.meeting_code + and self.passcode == other.passcode + and self.password == other.password + ) + + def __str__(self): + return "{} - '{}'".format(self.entry_point_type, self.uri) + + def __repr__(self): + return ''.format(self.__str__()) + + +class ConferenceSolution(_BaseConferenceSolution): + """Information about the conference solution, such as Hangouts or Google Meet.""" + + def __init__( + self, + entry_points: Union[EntryPoint, List[EntryPoint]], + solution_type: str = None, + name: str = None, + icon_uri: str = None, + conference_id: str = None, + signature: str = None, + notes: str = None + ): + """ + :param entry_points: + :py:class:`~gcsa.conference.EntryPoint` or list of :py:class:`~gcsa.conference.EntryPoint` s. + Information about individual conference entry points, such as URLs or phone numbers. + All of them must belong to the same conference. + :param solution_type: + Solution type. See :py:class:`~gcsa.conference.SolutionType` + + The possible values are: + + * HANGOUT - for Hangouts for consumers (hangouts.google.com) + * NAMED_HANGOUT - for classic Hangouts for Google Workspace users (hangouts.google.com) + * HANGOUTS_MEET - for Google Meet (meet.google.com) + * ADD_ON - for 3P conference providers + + :param name: + The user-visible name of this solution. Not localized. + :param icon_uri: + The user-visible icon for this solution. + :param conference_id: + The ID of the conference. Optional. + Can be used by developers to keep track of conferences, should not be displayed to users. + + Values for solution types (see :py:class:`~gcsa.conference.SolutionType`): + + * HANGOUT: unset + * NAMED_HANGOUT: the name of the Hangout + * HANGOUTS_MEET: the 10-letter meeting code, for example "aaa-bbbb-ccc" + * ADD_ON: defined by 3P conference provider + + :param signature: + The signature of the conference data. + Generated on server side. Must be preserved while copying the conference data between events, + otherwise the conference data will not be copied. + None for a conference with a failed create request. + Optional for a conference with a pending create request. + :param notes: + String of additional notes (such as instructions from the domain administrator, legal notices) + to display to the user. Can contain HTML. The maximum length is 2048 characters + """ + super().__init__(conference_id=conference_id, signature=signature, notes=notes) + + self.entry_points = [entry_points] if isinstance(entry_points, EntryPoint) else entry_points + self._check_entry_points() + + self.solution_type = solution_type + self.name = name + self.icon_uri = icon_uri + + def _check_entry_points(self): + """ + Checks counts of entry points types. + + * A conference can have zero or one `VIDEO` entry point. + * A conference can have zero or more `PHONE` entry points. + * A conference can have zero or one `SIP` entry point. + * A conference can have zero or one `MORE` entry point. + A conference with only a `MORE` entry point is not a valid conference. + """ + if len(self.entry_points) == 0: + raise ValueError('At least one entry point has to be provided.') + + video_count = 0 + sip_count = 0 + more_count = 0 + for ep in self.entry_points: + if ep.entry_point_type == EntryPoint.VIDEO: + video_count += 1 + elif ep.entry_point_type == EntryPoint.SIP: + sip_count += 1 + elif ep.entry_point_type == EntryPoint.MORE: + more_count += 1 + + if video_count > 1: + raise ValueError('A conference can have zero or one `VIDEO` entry point.') + if sip_count > 1: + raise ValueError('A conference can have zero or one `SIP` entry point.') + if more_count > 1: + raise ValueError('A conference can have zero or one `MORE` entry point.') + if more_count == len(self.entry_points): + raise ValueError('A conference with only a `MORE` entry point is not a valid conference.') + + def __eq__(self, other): + if not isinstance(other, ConferenceSolution): + return NotImplemented + elif self is other: + return True + else: + return ( + super().__eq__(other) + and self.entry_points == other.entry_points + and self.solution_type == other.solution_type + and self.name == other.name + and self.icon_uri == other.icon_uri + ) + + def __str__(self): + return '{} - {}'.format(self.solution_type, self.entry_points) + + def __repr__(self): + return ''.format(self.__str__()) + + +class ConferenceSolutionCreateRequest(_BaseConferenceSolution): + """ + A request to generate a new conference and attach it to the event. + The data is generated asynchronously. To see whether the data is present check the status field. + """ + + def __init__( + self, + solution_type: str = None, + request_id: str = None, + _status: str = None, + conference_id: str = None, + signature: str = None, + notes: str = None + ): + """ + :param solution_type: + Solution type. See :py:class:`~gcsa.conference.SolutionType` + + The possible values are: + + * HANGOUT - for Hangouts for consumers (hangouts.google.com) + * NAMED_HANGOUT - for classic Hangouts for Google Workspace users (hangouts.google.com) + * HANGOUTS_MEET - for Google Meet (meet.google.com) + * ADD_ON - for 3P conference providers + + :param request_id: + The client-generated unique ID for this request. + By default it is generated as UUID. + If you specify request_id manually, they should be unique for every new CreateRequest, + otherwise request will be ignored. + + :param _status: + The current status of the conference create request. Should not be set by developer. + + The possible values are: + + * "pending": the conference create request is still being processed. + * "failure": the conference create request failed, there are no entry points. + * "success": the conference create request succeeded, the entry points are populated. + In this case `ConferenceSolution` with created entry points + is stored in the event's `conference_data`. And `ConferenceSolutionCreateRequest` is omitted. + :param conference_id: + The ID of the conference. Optional. + Can be used by developers to keep track of conferences, should not be displayed to users. + + Values for solution types (see :py:class:`~gcsa.conference.SolutionType`): + + * HANGOUT: unset + * NAMED_HANGOUT: the name of the Hangout + * HANGOUTS_MEET: the 10-letter meeting code, for example "aaa-bbbb-ccc" + * ADD_ON: defined by 3P conference provider + + :param signature: + The signature of the conference data. + Generated on server side. Must be preserved while copying the conference data between events, + otherwise the conference data will not be copied. + None for a conference with a failed create request. + Optional for a conference with a pending create request. + :param notes: + String of additional notes (such as instructions from the domain administrator, legal notices) + to display to the user. Can contain HTML. The maximum length is 2048 characters + """ + super().__init__(conference_id=conference_id, signature=signature, notes=notes, _status=_status) + self.request_id = request_id or uuid4().hex + self.solution_type = solution_type + + def __eq__(self, other): + if not isinstance(other, ConferenceSolutionCreateRequest): + return NotImplemented + elif self is other: + return True + else: + return ( + super().__eq__(other) + and self.request_id == other.request_id + and self.solution_type == other.solution_type + and self.status == other.status + ) + + def __str__(self): + return "{} - status:'{}'".format(self.solution_type, self.status) + + def __repr__(self): + return ''.format(self.__str__()) diff --git a/google-calendar-simple-api/build/lib/gcsa/event.py b/google-calendar-simple-api/build/lib/gcsa/event.py new file mode 100644 index 0000000000000000000000000000000000000000..89b6cb0694e638d934ec3d09fe74b02010ffd544 --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/event.py @@ -0,0 +1,330 @@ +from functools import total_ordering +from typing import List, Union + +from beautiful_date import BeautifulDate +from tzlocal import get_localzone_name +from datetime import datetime, date, timedelta, time + +from ._resource import Resource +from .attachment import Attachment +from .attendee import Attendee +from .conference import ConferenceSolution, ConferenceSolutionCreateRequest +from .person import Person +from .reminders import PopupReminder, EmailReminder, Reminder +from .util.date_time_util import ensure_localisation + + +class Visibility: + """Possible values of the event visibility. + + * `DEFAULT` - Uses the default visibility for events on the calendar. This is the default value. + * `PUBLIC` - The event is public and event details are visible to all readers of the calendar. + * `PRIVATE` - The event is private and only event attendees may view event details. + """ + + DEFAULT = "default" + PUBLIC = "public" + PRIVATE = "private" + + +class Transparency: + """Possible values of the event transparency. + + * `OPAQUE` - Default value. The event does block time on the calendar. + This is equivalent to setting 'Show me as' to 'Busy' in the Calendar UI. + * `TRANSPARENT` - The event does not block time on the calendar. + This is equivalent to setting 'Show me as' to 'Available' in the Calendar UI. + """ + + OPAQUE = 'opaque' + TRANSPARENT = 'transparent' + + +@total_ordering +class Event(Resource): + def __init__( + self, + summary: str, + start: Union[date, datetime, BeautifulDate], + end: Union[date, datetime, BeautifulDate] = None, + *, + timezone: str = get_localzone_name(), + event_id: str = None, + description: str = None, + location: str = None, + recurrence: Union[str, List[str]] = None, + color_id: str = None, + visibility: str = Visibility.DEFAULT, + attendees: Union[Attendee, str, List[Attendee], List[str]] = None, + attachments: Union[Attachment, List[Attachment]] = None, + conference_solution: Union[ConferenceSolution, ConferenceSolutionCreateRequest] = None, + reminders: Union[Reminder, List[Reminder]] = None, + default_reminders: bool = False, + minutes_before_popup_reminder: int = None, + minutes_before_email_reminder: int = None, + guests_can_invite_others: bool = True, + guests_can_modify: bool = False, + guests_can_see_other_guests: bool = True, + transparency: str = None, + _creator: Person = None, + _organizer: Person = None, + _created: datetime = None, + _updated: datetime = None, + _recurring_event_id: str = None, + **other + ): + """ + :param summary: + Title of the event. + :param start: + Starting date/datetime. + :param end: + Ending date/datetime. If 'end' is not specified, event is considered as a 1-day or 1-hour event + if 'start' is date or datetime respectively. + :param timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". By default, + the computers local timezone is used if it is configured. UTC is used otherwise. + :param event_id: + Opaque identifier of the event. By default, it is generated by the server. You can specify id as a + 5-1024 long string of characters used in base32hex ([a-vA-V0-9]). The ID must be unique per + calendar. + :param description: + Description of the event. Can contain HTML. + :param location: + Geographic location of the event as free-form text. + :param recurrence: + RRULE/RDATE/EXRULE/EXDATE string or list of such strings. See :py:mod:`~gcsa.recurrence` + :param color_id: + Color id referring to an entry from colors endpoint. + See :py:meth:`~gcsa.google_calendar.GoogleCalendar.list_event_colors` + :param visibility: + Visibility of the event. Default is default visibility for events on the calendar. + See :py:class:`~gcsa.event.Visibility` + :param attendees: + Attendee or list of attendees. See :py:class:`~gcsa.attendee.Attendee`. + Each attendee may be given as email string or :py:class:`~gcsa.attendee.Attendee` object. + :param attachments: + Attachment or list of attachments. See :py:class:`~gcsa.attachment.Attachment` + :param conference_solution: + :py:class:`~gcsa.conference.ConferenceSolutionCreateRequest` object to create a new conference + or :py:class:`~gcsa.conference.ConferenceSolution` object for existing conference. + :param reminders: + Reminder or list of reminder objects. See :py:mod:`~gcsa.reminders` + :param default_reminders: + Whether the default reminders of the calendar apply to the event. + :param minutes_before_popup_reminder: + Minutes before popup reminder or None if reminder is not needed. + :param minutes_before_email_reminder: + Minutes before email reminder or None if reminder is not needed. + :param guests_can_invite_others: + Whether attendees other than the organizer can invite others to the event. + :param guests_can_modify: + Whether attendees other than the organizer can modify the event. + :param guests_can_see_other_guests: + Whether attendees other than the organizer can see who the event's attendees are. + :param transparency: + Whether the event blocks time on the calendar. See :py:class:`~gcsa.event.Transparency` + :param _creator: + The creator of the event. See :py:class:`~gcsa.person.Person` + :param _organizer: + The organizer of the event. See :py:class:`~gcsa.person.Person`. + If the organizer is also an attendee, this is indicated with a separate entry in attendees with + the organizer field set to True. + To change the organizer, use the move operation + see :py:meth:`~gcsa.google_calendar.GoogleCalendar.move_event` + :param _created: + Creation time of the event. Read-only. + :param _updated: + Last modification time of the event. Read-only. + :param _recurring_event_id: + For an instance of a recurring event, this is the id of the recurring event to which + this instance belongs. Read-only. + :param other: + Other fields that should be included in request json. Will be included as they are. + See more in https://developers.google.com/calendar/v3/reference/events + """ + + def ensure_list(obj): + return [] if obj is None else obj if isinstance(obj, list) else [obj] + + self.timezone = timezone + self.start = start + if end or start is None: + self.end = end + elif isinstance(start, datetime): + self.end = start + timedelta(hours=1) + elif isinstance(start, date): + self.end = start + timedelta(days=1) + + if isinstance(self.start, datetime) and isinstance(self.end, datetime): + self.start = ensure_localisation(self.start, timezone) + self.end = ensure_localisation(self.end, timezone) + elif isinstance(self.start, datetime) or isinstance(self.end, datetime): + raise TypeError('Start and end must either both be date or both be datetime.') + + def ensure_date(d): + """Converts d to date if it is of type BeautifulDate.""" + if isinstance(d, BeautifulDate): + return date(year=d.year, month=d.month, day=d.day) + else: + return d + + self.start = ensure_date(self.start) + self.end = ensure_date(self.end) + + self.created = _created + self.updated = _updated + + attendees = [self._ensure_attendee_from_email(a) for a in ensure_list(attendees)] + reminders = ensure_list(reminders) + + if len(reminders) > 5: + raise ValueError('The maximum number of override reminders is 5.') + + if default_reminders and reminders: + raise ValueError('Cannot specify both default reminders and overrides at the same time.') + + self.event_id = event_id + self.summary = summary + self.description = description + self.location = location + self.recurrence = ensure_list(recurrence) + self.color_id = color_id + self.visibility = visibility + self.attendees = attendees + self.attachments = ensure_list(attachments) + self.conference_solution = conference_solution + self.reminders = reminders + self.default_reminders = default_reminders + self.recurring_event_id = _recurring_event_id + self.guests_can_invite_others = guests_can_invite_others + self.guests_can_modify = guests_can_modify + self.guests_can_see_other_guests = guests_can_see_other_guests + self.transparency = transparency + self.creator = _creator + self.organizer = _organizer + + self.other = other + + if minutes_before_popup_reminder is not None: + self.add_popup_reminder(minutes_before_popup_reminder) + if minutes_before_email_reminder is not None: + self.add_email_reminder(minutes_before_email_reminder) + + @property + def id(self): + return self.event_id + + def add_attendee( + self, + attendee: Union[str, Attendee] + ): + """Adds attendee to an event. See :py:class:`~gcsa.attendee.Attendee`. + Attendee may be given as email string or :py:class:`~gcsa.attendee.Attendee` object.""" + self.attendees.append(self._ensure_attendee_from_email(attendee)) + + def add_attendees( + self, + attendees: List[Union[str, Attendee]] + ): + """Adds multiple attendees to an event. See :py:class:`~gcsa.attendee.Attendee`. + Each attendee may be given as email string or :py:class:`~gcsa.attendee.Attendee` object.""" + for a in attendees: + self.add_attendee(a) + + def add_attachment( + self, + file_url: str, + title: str = None, + mime_type: str = None + ): + """Adds attachment to an event. See :py:class:`~gcsa.attachment.Attachment`""" + self.attachments.append(Attachment(file_url=file_url, title=title, mime_type=mime_type)) + + def add_email_reminder( + self, + minutes_before_start: int = None, + days_before: int = None, + at: time = None + ): + """Adds email reminder to an event. See :py:class:`~gcsa.reminders.EmailReminder`""" + self.add_reminder(EmailReminder(minutes_before_start, days_before, at)) + + def add_popup_reminder( + self, + minutes_before_start: int = None, + days_before: int = None, + at: time = None + ): + """Adds popup reminder to an event. See :py:class:`~gcsa.reminders.PopupReminder`""" + self.add_reminder(PopupReminder(minutes_before_start, days_before, at)) + + def add_reminder( + self, + reminder: Reminder + ): + """Adds reminder to an event. See :py:mod:`~gcsa.reminders`""" + if len(self.reminders) > 4: + raise ValueError('The maximum number of override reminders is 5.') + self.reminders.append(reminder) + + @staticmethod + def _ensure_attendee_from_email( + attendee_or_email: Union[str, Attendee] + ): + """If attendee_or_email is email string, returns created :py:class:`~gcsa.attendee.Attendee` + object with the given email.""" + if isinstance(attendee_or_email, str): + return Attendee(email=attendee_or_email) + else: + return attendee_or_email + + @property + def is_recurring_instance(self): + return self.recurring_event_id is not None + + def __str__(self): + return '{} - {}'.format(self.start, self.summary) + + def __repr__(self): + return ''.format(self.__str__()) + + def __lt__(self, other): + def ensure_datetime(d, timezone): + if type(d) is date: + return ensure_localisation(datetime(year=d.year, month=d.month, day=d.day), timezone) + else: + return d + + start = ensure_datetime(self.start, self.timezone) + end = ensure_datetime(self.end, self.timezone) + + other_start = ensure_datetime(other.start, other.timezone) + other_end = ensure_datetime(other.end, other.timezone) + + return (start, end) < (other_start, other_end) + + def __eq__(self, other): + return ( + isinstance(other, Event) + and self.start == other.start + and self.end == other.end + and self.event_id == other.event_id + and self.summary == other.summary + and self.description == other.description + and self.location == other.location + and self.recurrence == other.recurrence + and self.color_id == other.color_id + and self.visibility == other.visibility + and self.attendees == other.attendees + and self.attachments == other.attachments + and self.reminders == other.reminders + and self.default_reminders == other.default_reminders + and self.created == other.created + and self.updated == other.updated + and self.recurring_event_id == other.recurring_event_id + and self.guests_can_invite_others == other.guests_can_invite_others + and self.guests_can_modify == other.guests_can_modify + and self.guests_can_see_other_guests == other.guests_can_see_other_guests + and self.other == other.other + ) diff --git a/google-calendar-simple-api/build/lib/gcsa/free_busy.py b/google-calendar-simple-api/build/lib/gcsa/free_busy.py new file mode 100644 index 0000000000000000000000000000000000000000..6da47bac97f98a73eacf7e187a6eec5b0f9106b2 --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/free_busy.py @@ -0,0 +1,98 @@ +import json +from collections import namedtuple +from datetime import datetime +from typing import Dict, List + +TimeRange = namedtuple('TimeRange', ('start', 'end')) + + +class FreeBusy: + def __init__( + self, + *, + time_min: datetime, + time_max: datetime, + groups: Dict[str, List[str]], + calendars: Dict[str, List[TimeRange]], + groups_errors: Dict = None, + calendars_errors: Dict = None, + ): + """Represents free/busy information for a given calendar(s) and/or group(s) + + :param time_min: + The start of the interval. + :param time_max: + The end of the interval. + :param groups: + Expansion of groups. + Dictionary that maps the name of the group to the list of calendars that are members of this group. + :param calendars: + Free/busy information for calendars. + Dictionary that maps calendar id to the list of time ranges during which this calendar should be + regarded as busy. + :param groups_errors: + Optional error(s) (if computation for the group failed). + Dictionary that maps the name of the group to the list of errors. + :param calendars_errors: + Optional error(s) (if computation for the calendar failed). + Dictionary that maps calendar id to the list of errors. + + + .. note:: Errors have the following format: + + .. code-block:: + + { + "domain": "", + "reason": "" + } + + Some possible values for "reason" are: + + * "groupTooBig" - The group of users requested is too large for a single query. + * "tooManyCalendarsRequested" - The number of calendars requested is too large for a single query. + * "notFound" - The requested resource was not found. + * "internalError" - The API service has encountered an internal error. + + Additional error types may be added in the future. + """ + self.time_min = time_min + self.time_max = time_max + self.groups = groups + self.calendars = calendars + self.groups_errors = groups_errors or {} + self.calendars_errors = calendars_errors or {} + + def __iter__(self): + """ + :returns: + list of 'TimeRange's during which this calendar should be regarded as busy. + :raises: + ValueError if requested all requested calendars have errors + or more than one calendar has been requested. + """ + if len(self.calendars) == 0: + raise ValueError("No free/busy information has been received. " + "Check the 'calendars_errors' and 'groups_errors' fields.") + if len(self.calendars) > 1 or len(self.calendars_errors) > 0: + raise ValueError("Can't iterate over FreeBusy objects directly when more than one calendars were requested." + "Use 'calendars' field instead to get free/busy information of the specific calendar.") + return iter(next(iter(self.calendars.values()))) + + def __str__(self): + return ''.format(self.time_min, self.time_max) + + def __repr__(self): + return self.__str__() + + +class FreeBusyQueryError(Exception): + def __init__(self, groups_errors, calendars_errors): + message = '\n' + if groups_errors: + message += f'Groups errors: {json.dumps(groups_errors, indent=4)}' + if calendars_errors: + message += f'Calendars errors: {json.dumps(calendars_errors, indent=4)}' + super().__init__(message) + self.groups_errors = groups_errors + self.calendars_errors = calendars_errors diff --git a/google-calendar-simple-api/build/lib/gcsa/google_calendar.py b/google-calendar-simple-api/build/lib/gcsa/google_calendar.py new file mode 100644 index 0000000000000000000000000000000000000000..65e8a8d9d63e21e30ab7f7ded1167a2de3aa7bd5 --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/google_calendar.py @@ -0,0 +1,81 @@ +from google.oauth2.credentials import Credentials + +from ._services.acl_service import ACLService +from ._services.events_service import EventsService, SendUpdatesMode # noqa: F401 +from ._services.calendars_service import CalendarsService +from ._services.calendar_lists_service import CalendarListService +from ._services.colors_service import ColorsService +from ._services.free_busy_service import FreeBusyService +from ._services.settings_service import SettingsService + + +class GoogleCalendar( + EventsService, + CalendarsService, + CalendarListService, + ColorsService, + SettingsService, + ACLService, + FreeBusyService +): + """Collection of all supported methods for events and calendars management.""" + + def __init__( + self, + default_calendar: str = 'primary', + *, + credentials: Credentials = None, + credentials_path: str = None, + token_path: str = None, + save_token: bool = True, + read_only: bool = False, + authentication_flow_host: str = 'localhost', + authentication_flow_port: int = 8080, + authentication_flow_bind_addr: str = None + ): + """ + Specify ``credentials`` to use in requests or ``credentials_path`` and ``token_path`` to get credentials from. + + :param default_calendar: + Users email address or name/id of the calendar. Default: primary calendar of the user + + If user's email or "primary" is specified, then primary calendar of the user is used. + You don't need to specify this parameter in this case as it is a default behaviour. + + To use a different calendar you need to specify its id. + Go to calendar's `settings and sharing` -> `Integrate calendar` -> `Calendar ID`. + :param credentials: + Credentials with token and refresh token. + If specified, ``credentials_path``, ``token_path``, and ``save_token`` are ignored. + If not specified, credentials are retrieved from "token.pickle" file (specified in ``token_path`` or + default path) or with authentication flow using secret from "credentials.json" ("client_secret_*.json") + (specified in ``credentials_path`` or default path) + :param credentials_path: + Path to "credentials.json" ("client_secret_*.json") file. + Default: ~/.credentials/credentials.json or ~/.credentials/client_secret*.json + :param token_path: + Existing path to load the token from, or path to save the token after initial authentication flow. + Default: "token.pickle" in the same directory as the credentials_path + :param save_token: + Whether to pickle token after authentication flow for future uses + :param read_only: + If require read only access. Default: False + :param authentication_flow_host: + Host to receive response during authentication flow + :param authentication_flow_port: + Port to receive response during authentication flow + :param authentication_flow_bind_addr: + Optional IP address for the redirect server to listen on when it is not the same as host + (e.g. in a container) + """ + super().__init__( + default_calendar=default_calendar, + credentials=credentials, + credentials_path=credentials_path, + token_path=token_path, + save_token=save_token, + read_only=read_only, + authentication_flow_host=authentication_flow_host, + authentication_flow_port=authentication_flow_port, + authentication_flow_bind_addr=authentication_flow_bind_addr + ) diff --git a/google-calendar-simple-api/build/lib/gcsa/person.py b/google-calendar-simple-api/build/lib/gcsa/person.py new file mode 100644 index 0000000000000000000000000000000000000000..e663c0724a3c31073339053c066b5efcc00f1268 --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/person.py @@ -0,0 +1,41 @@ +class Person: + def __init__( + self, + email: str = None, + display_name: str = None, + _id: str = None, + _is_self: bool = None + ): + """Represents organizer's, creator's, or primary attendee's fields. + For attendees see more in :py:class:`~gcsa.attendee.Attendee`. + + :param email: + The person's email address, if available + :param display_name: + The person's name, if available + :param _id: + The person's Profile ID, if available. + It corresponds to the id field in the People collection of the Google+ API + :param _is_self: + Whether the person corresponds to the calendar on which the copy of the event appears. + The default is False (set by Google's API). + """ + self.email = email + self.display_name = display_name + self.id_ = _id + self.is_self = _is_self + + def __eq__(self, other): + return ( + isinstance(other, Person) + and self.email == other.email + and self.display_name == other.display_name + and self.id_ == other.id_ + and self.is_self == other.is_self + ) + + def __str__(self): + return "'{}' - '{}'".format(self.email, self.display_name) + + def __repr__(self): + return ''.format(self.__str__()) diff --git a/google-calendar-simple-api/build/lib/gcsa/recurrence.py b/google-calendar-simple-api/build/lib/gcsa/recurrence.py new file mode 100644 index 0000000000000000000000000000000000000000..3721effb04c212a391559fa482aee81e0fed3b47 --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/recurrence.py @@ -0,0 +1,570 @@ +from datetime import datetime, date + +from tzlocal import get_localzone_name + +from .util.date_time_util import ensure_localisation + + +class Duration: + """Represents properties that contain a duration of time.""" + + def __init__(self, w=None, d=None, h=None, m=None, s=None): + """ + :param w: weeks + :param d: days + :param h: hours + :param m: minutes + :param s: seconds + """ + + self.w = w + self.d = d + self.h = h + self.m = m + self.s = s + + def __str__(self): + res = 'P' + if self.w: + res += '{}W'.format(self.w) + if self.d: + res += '{}D'.format(self.d) + if self.h or self.m or self.s: + res += 'T' + if self.h: + res += '{}H'.format(self.h) + if self.m: + res += '{}M'.format(self.m) + if self.s: + res += '{}S'.format(self.s) + + return res + + +class _DayOfTheWeek: + """Weekday representation. Optionally includes positive or negative integer + value that indicates the nth occurrence of a specific day within the "MONTHLY" + or "YEARLY" recurrence rules. + + >>> str(SU) + 'SU' + + >>> str(FR) + 'FR' + + >>> str(SU(4)) + '4SU' + + >>> str(SU(-1)) + '-1SU' + """ + + def __init__(self, short, n=None): + self.short = short + self.n = n + + def __call__(self, n): + return _DayOfTheWeek(self.short, n) + + def __str__(self): + if self.n is None: + return self.short + else: + return str(self.n) + self.short + + +SU = SUNDAY = _DayOfTheWeek('SU') +MO = MONDAY = _DayOfTheWeek('MO') +TU = TUESDAY = _DayOfTheWeek('TU') +WE = WEDNESDAY = _DayOfTheWeek('WE') +TH = THURSDAY = _DayOfTheWeek('TH') +FR = FRIDAY = _DayOfTheWeek('FR') +SA = SATURDAY = _DayOfTheWeek('SA') + +DEFAULT_WEEK_START = SUNDAY + +SECONDLY = 'SECONDLY' +MINUTELY = 'MINUTELY' +HOURLY = 'HOURLY' + +DAILY = 'DAILY' +WEEKLY = 'WEEKLY' +MONTHLY = 'MONTHLY' +YEARLY = 'YEARLY' + + +class Recurrence: + + @staticmethod + def rule( + freq=DAILY, + interval=None, + count=None, + until=None, + by_second=None, + by_minute=None, + by_hour=None, + by_week_day=None, + by_month_day=None, + by_year_day=None, + by_week=None, + by_month=None, + by_set_pos=None, + week_start=DEFAULT_WEEK_START + ): + """This property defines a rule or repeating pattern for recurring events. + + :param freq: + Identifies the type of recurrence rule. Possible values are SECONDLY, HOURLY, + MINUTELY, DAILY, WEEKLY, MONTHLY, YEARLY. Default: DAILY + :param interval: + Positive integer representing how often the recurrence rule repeats + :param count: + Number of occurrences at which to range-bound the recurrence + :param until: + End date of recurrence + :param by_second: + Second or list of seconds within a minute. Valid values are 0 to 60 + :param by_minute: + Minute or list of minutes within a hour. Valid values are 0 to 59 + :param by_hour: + Hour or list of hours of the day. Valid values are 0 to 23 + :param by_week_day: + Day or list of days of the week. + Possible values: :py:obj:`~SUNDAY`, :py:obj:`~MONDAY`, :py:obj:`~TUESDAY`, :py:obj:`~WEDNESDAY`, + :py:obj:`~THURSDAY`, :py:obj:`~FRIDAY`, :py:obj:`~SATURDAY` + :param by_month_day: + Day or list of days of the month. Valid values are 1 to 31 or -31 to -1. + For example, -10 represents the tenth to the last day of the month. + :param by_year_day: + Day or list of days of the year. Valid values are 1 to 366 or -366 to -1. + For example, -1 represents the last day of the year. + :param by_week: + Ordinal or list of ordinals specifying weeks of the year. Valid values are 1 to 53 or -53 to -1. + :param by_month: + Month or list of months of the year. Valid values are 1 to 12. + :param by_set_pos: + Value or list of values which corresponds to the nth occurrence within the set of events + specified by the rule. Valid values are 1 to 366 or -366 to -1. + It can only be used in conjunction with another by_xxx parameter. + :param week_start: + The day on which the workweek starts. + Possible values: :py:obj:`~SUNDAY`, :py:obj:`~MONDAY`, :py:obj:`~TUESDAY`, :py:obj:`~WEDNESDAY`, + :py:obj:`~THURSDAY`, :py:obj:`~FRIDAY`, :py:obj:`~SATURDAY` + + :return: + String representing specified recurrence rule in `RRULE format`_. + + .. note:: If none of the by_day, by_month_day, or by_year_day are specified, the day is gotten from start date. + + + .. _`RRULE format`: https://tools.ietf.org/html/rfc5545#section-3.8.5 + """ + return 'RRULE:' + Recurrence._rule(freq, interval, count, until, by_second, by_minute, by_hour, by_week_day, + by_month_day, by_year_day, by_week, by_month, by_set_pos, week_start) + + @staticmethod + def exclude_rule( + freq=DAILY, + interval=None, + count=None, + until=None, + by_second=None, + by_minute=None, + by_hour=None, + by_week_day=None, + by_month_day=None, + by_year_day=None, + by_week=None, + by_month=None, + by_set_pos=None, + week_start=DEFAULT_WEEK_START + ): + """This property defines an exclusion rule or repeating pattern for recurring events. + + :param freq: + Identifies the type of recurrence rule. Possible values are SECONDLY, HOURLY, + MINUTELY, DAILY, WEEKLY, MONTHLY, YEARLY. Default: DAILY + :param interval: + Positive integer representing how often the recurrence rule repeats + :param count: + Number of occurrences at which to range-bound the recurrence + :param until: + End date of recurrence + :param by_second: + Second or list of seconds within a minute. Valid values are 0 to 60 + :param by_minute: + Minute or list of minutes within a hour. Valid values are 0 to 59 + :param by_hour: + Hour or list of hours of the day. Valid values are 0 to 23 + :param by_week_day: + Day or list of days of the week. + Possible values: :py:obj:`~SUNDAY`, :py:obj:`~MONDAY`, :py:obj:`~TUESDAY`, :py:obj:`~WEDNESDAY`, + :py:obj:`~THURSDAY`, :py:obj:`~FRIDAY`, :py:obj:`~SATURDAY` + :param by_month_day: + Day or list of days of the month. Valid values are 1 to 31 or -31 to -1. + For example, -10 represents the tenth to the last day of the month. + :param by_year_day: + Day or list of days of the year. Valid values are 1 to 366 or -366 to -1. + For example, -1 represents the last day of the year. + :param by_week: + Ordinal or list of ordinals specifying weeks of the year. Valid values are 1 to 53 or -53 to -1. + :param by_month: + Month or list of months of the year. Valid values are 1 to 12. + :param by_set_pos: + Value or list of values which corresponds to the nth occurrence within the set of events + specified by the rule. Valid values are 1 to 366 or -366 to -1. + It can only be used in conjunction with another by_xxx parameter. + :param week_start: + The day on which the workweek starts. + Possible values: :py:obj:`~SUNDAY`, :py:obj:`~MONDAY`, :py:obj:`~TUESDAY`, :py:obj:`~WEDNESDAY`, + :py:obj:`~THURSDAY`, :py:obj:`~FRIDAY`, :py:obj:`~SATURDAY` + + :return: + String representing specified recurrence rule in `RRULE format`_. + + .. note:: If none of the by_day, by_month_day, or by_year_day are specified, the day is gotten from start date. + + + .. _`RRULE format`: https://tools.ietf.org/html/rfc5545#section-3.8.5 + """ + return 'EXRULE:' + Recurrence._rule(freq, interval, count, until, by_second, by_minute, by_hour, by_week_day, + by_month_day, by_year_day, by_week, by_month, by_set_pos, week_start) + + @staticmethod + def dates(ds): + """Converts date(s) set to RDATE format. + + :param ds: + date/datetime object or list of date/datetime objects + + :return: + RDATE string of dates. + """ + return 'RDATE;' + Recurrence._dates(ds) + + @staticmethod + def times(dts, timezone=get_localzone_name()): + """Converts datetime(s) set to RDATE format. + + :param dts: + datetime object or list of datetime objects + :param timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". By default, + the computers local timezone is used if it is configured. UTC is used otherwise. + + :return: + RDATE string of datetimes with specified timezone. + """ + return 'RDATE;' + Recurrence._times(dts, timezone) + + @staticmethod + def periods(ps, timezone=get_localzone_name()): + """Converts date period(s) to RDATE format. + + Period is defined as tuple of starting date/datetime and ending date/datetime or duration as Duration object: + (date/datetime, date/datetime/Duration) + + :param ps: + Period or list of periods. + :param timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". By default, + the computers local timezone is used if it is configured. UTC is used otherwise. + + :return: + RDATE string of periods. + """ + return 'RDATE;' + Recurrence._periods(ps, timezone) + + @staticmethod + def exclude_dates(ds): + """Converts date(s) set to EXDATE format. + + :param ds: + date/datetime object or list of date/datetime objects + + :return: + EXDATE string of dates. + """ + return 'EXDATE;' + Recurrence._dates(ds) + + @staticmethod + def exclude_times(dts, timezone=get_localzone_name()): + """Converts datetime(s) set to EXDATE format. + + :param dts: + datetime object or list of datetime objects + :param timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". By default, + the computers local timezone is used if it is configured. UTC is used otherwise. + + :return: + EXDATE string of datetimes with specified timezone. + """ + return 'EXDATE;' + Recurrence._times(dts, timezone) + + @staticmethod + def exclude_periods(ps, timezone=get_localzone_name()): + """Converts date period(s) to EXDATE format. + + Period is defined as tuple of starting date/datetime and ending date/datetime or duration as Duration object: + (date/datetime, date/datetime/Duration) + + :param ps: + Period or list of periods. + :param timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". By default, + the computers local timezone is used if it is configured. UTC is used otherwise. + + :return: + EXDATE string of periods. + """ + return 'EXDATE;' + Recurrence._periods(ps, timezone) + + @staticmethod + def _times(dts, timezone=get_localzone_name()): + """Converts datetime(s) set to RDATE format. + + :param dts: + datetime object or list of datetime objects + :param timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". By default, + the computers local timezone is used if it is configured. UTC is used otherwise. + + :return: + RDATE string of datetimes with specified timezone. + """ + + if not isinstance(dts, list): + dts = [dts] + + localized_datetimes = [] + for dt in dts: + if not isinstance(dt, (date, datetime)): + msg = 'The dts object(s) must be date or datetime, not {!r}.'.format(dt.__class__.__name__) + raise TypeError(msg) + localized_datetimes.append(ensure_localisation(dt, timezone)) + + return 'TZID={}:{}'.format(timezone, ','.join(d.strftime('%Y%m%dT%H%M%S') for d in localized_datetimes)) + + @staticmethod + def _dates(ds): + """Converts date(s) set to RDATE format. + + :param ds: + date/datetime object or list of date/datetime objects + + :return: + RDATE string of dates. + """ + if not isinstance(ds, list): + ds = [ds] + + for d in ds: + if not (isinstance(d, (date, datetime))): + msg = 'The dates object(s) must be date or datetime, not {!r}.'.format(d.__class__.__name__) + raise TypeError(msg) + + return 'VALUE=DATE:' + ','.join(d.strftime('%Y%m%d') for d in ds) + + @staticmethod + def _periods(ps, timezone=get_localzone_name()): + """Converts date period(s) to RDATE format. + + Period is defined as tuple of starting date/datetime and ending date/datetime or duration as Duration object: + (date/datetime, date/datetime/Duration) + + :param ps: + Period or list of periods. + :param timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". By default, + the computers local timezone is used if it is configured. UTC is used otherwise. + + :return: + RDATE string of periods. + """ + if not isinstance(ps, list): + ps = [ps] + + period_strings = [] + for start, end in ps: + if not isinstance(start, (date, datetime)): + msg = 'The start object(s) must be a date or datetime, not {!r}.'.format(end.__class__.__name__) + raise TypeError(msg) + + start = ensure_localisation(start, timezone) + if isinstance(end, (date, datetime)): + end = ensure_localisation(end, timezone) + pstr = '{}/{}'.format(start.strftime('%Y%m%dT%H%M%SZ'), end.strftime('%Y%m%dT%H%M%SZ')) + elif isinstance(end, Duration): + pstr = '{}/{}'.format(start.strftime('%Y%m%dT%H%M%SZ'), end) + else: + msg = 'The end object(s) must be a date, datetime or Duration, not {!r}.'.format(end.__class__.__name__) + raise TypeError(msg) + period_strings.append(pstr) + + return 'VALUE=PERIOD:' + ','.join(period_strings) + + @staticmethod + def _rule( + freq=DAILY, + interval=None, + count=None, + until=None, + by_second=None, # BYSECOND + by_minute=None, # BYMINUTE + by_hour=None, # BYHOUR + by_week_day=None, # BYDAY + by_month_day=None, # BYMONTHDAY + by_year_day=None, # BYYEARDAY + by_week=None, # BYWEEKNO + by_month=None, # BYMONTH + by_set_pos=None, # BYSETPOS + week_start=DEFAULT_WEEK_START # WKST + ): + """This property defines a rule or repeating pattern for recurring events. + + :param freq: + Identifies the type of recurrence rule. Possible values are SECONDLY, HOURLY, + MINUTELY, DAILY, WEEKLY, MONTHLY, YEARLY. Default: DAILY + :param interval: + Positive integer representing how often the recurrence rule repeats + :param count: + Number of occurrences at which to range-bound the recurrence + :param until: + End date of recurrence + :param by_second: + Second or list of seconds within a minute. Valid values are 0 to 60 + :param by_minute: + Minute or list of minutes within a hour. Valid values are 0 to 59 + :param by_hour: + Hour or list of hours of the day. Valid values are 0 to 23 + :param by_week_day: + Day or list of days of the week. + Possible values: :py:obj:`~SUNDAY`, :py:obj:`~MONDAY`, :py:obj:`~TUESDAY`, :py:obj:`~WEDNESDAY`, + :py:obj:`~THURSDAY`, :py:obj:`~FRIDAY`, :py:obj:`~SATURDAY` + :param by_month_day: + Day or list of days of the month. Valid values are 1 to 31 or -31 to -1. + For example, -10 represents the tenth to the last day of the month. + :param by_year_day: + Day or list of days of the year. Valid values are 1 to 366 or -366 to -1. + For example, -1 represents the last day of the year. + :param by_week: + Ordinal or list of ordinals specifying weeks of the year. Valid values are 1 to 53 or -53 to -1. + :param by_month: + Month or list of months of the year. Valid values are 1 to 12. + :param by_set_pos: + Value or list of values which corresponds to the nth occurrence within the set of events + specified by the rule. Valid values are 1 to 366 or -366 to -1. + It can only be used in conjunction with another by_xxx parameter. + :param week_start: + The day on which the workweek starts. + Possible values: :py:obj:`~SUNDAY`, :py:obj:`~MONDAY`, :py:obj:`~TUESDAY`, :py:obj:`~WEDNESDAY`, + :py:obj:`~THURSDAY`, :py:obj:`~FRIDAY`, :py:obj:`~SATURDAY` + + :return: + String representing specified recurrence rule in `RRULE format`_. + + .. note:: If none of the by_day, by_month_day, or by_year_day are specified, the day is gotten from start date. + + + .. _`RRULE format`: https://tools.ietf.org/html/rfc5545#section-3.8.5 + """ + + def ensure_iterable(it): + return it if isinstance(it, (list, tuple, set)) else [it] if it is not None else [] + + def check_all_type(it, type_, name): + if any(not isinstance(o, type_) for o in it): + raise TypeError('"{}" parameter must be a {} or list of {}s.' + .format(name, type_.__name__, type_.__name__)) + + def check_all_type_and_range(it, type_, range_, name, nonzero=False): + check_all_type(it, type_, name) + low, high = range_ + if any(not (low <= o <= high) for o in it): + raise ValueError('"{}" parameter must be in range {}-{}.' + .format(name, low, high)) + if nonzero and any(o == 0 for o in it): + raise ValueError('"{}" parameter must be in range {}-{} and nonzero.' + .format(name, low, high)) + + def to_string(values): + return ','.join(map(str, values)) if values else None + + if freq not in (SECONDLY, MINUTELY, HOURLY, DAILY, WEEKLY, MONTHLY, YEARLY): + raise ValueError('"freq" parameter must be one of SECONDLY, HOURLY, MINUTELY, DAILY, ' + 'WEEKLY, MONTHLY or YEARLY. {} was provided'.format(freq)) + if interval is not None and (not isinstance(interval, int) or interval < 1): + raise ValueError('"interval" parameter must be a positive int. ' + '{} was provided'.format(interval)) + if count is not None and (not isinstance(count, int) or count < 1): + raise ValueError('"count" parameter must be a positive int. ' + '{} was provided'.format(count)) + if until is not None: + if not isinstance(until, (date, datetime)): + raise TypeError('The until object must be a date or datetime, ' + 'not {!r}.'.format(until.__class__.__name__)) + else: + until = until.strftime("%Y%m%dT%H%M%SZ") + if count is not None and until is not None: + raise ValueError('"count" and "until" may not appear in one recurrence rule.') + + by_second = ensure_iterable(by_second) + check_all_type_and_range(by_second, int, (0, 60), "by_second") + + by_minute = ensure_iterable(by_minute) + check_all_type_and_range(by_minute, int, (0, 59), "by_minute") + + by_hour = ensure_iterable(by_hour) + check_all_type_and_range(by_hour, int, (0, 23), "by_hour") + + by_week_day = ensure_iterable(by_week_day) + check_all_type(by_week_day, _DayOfTheWeek, "by_week_day") + + by_month_day = ensure_iterable(by_month_day) + check_all_type_and_range(by_month_day, int, (-31, 31), "by_month_day", nonzero=True) + + by_year_day = ensure_iterable(by_year_day) + check_all_type_and_range(by_year_day, int, (-366, 366), "by_year_day", nonzero=True) + + by_week = ensure_iterable(by_week) + check_all_type_and_range(by_week, int, (-53, 53), "by_week", nonzero=True) + + by_month = ensure_iterable(by_month) + check_all_type_and_range(by_month, int, (1, 12), "by_month") + + by_set_pos = ensure_iterable(by_set_pos) + check_all_type_and_range(by_set_pos, int, (-366, 366), "by_set_pos", nonzero=True) + if by_set_pos and all(not v for v in (by_second, by_minute, by_hour, + by_week_day, by_month_day, by_year_day, + by_week, by_month)): + raise ValueError('"by_set_pos" parameter can only be used in conjunction with another by_xxx parameter.') + + if not isinstance(week_start, _DayOfTheWeek): + raise ValueError('"week_start" parameter must be one of SUNDAY, MONDAY, etc. ' + '{} was provided'.format(week_start)) + + rrule = 'FREQ={}'.format(freq) + + rule_properties = ( + ('INTERVAL', interval), + ('COUNT', count), + ('UNTIL', until), + ('BYSECOND', to_string(by_second)), + ('BYMINUTE', to_string(by_minute)), + ('BYHOUR', to_string(by_hour)), + ('BYDAY', to_string(by_week_day)), + ('BYMONTHDAY', to_string(by_month_day)), + ('BYYEARDAY', to_string(by_year_day)), + ('BYWEEKNO', to_string(by_week)), + ('BYMONTH', to_string(by_month)), + ('BYSETPOS', to_string(by_set_pos)), + ('WKST', week_start) + ) + + for key, value in rule_properties: + if value: + rrule += ';{}={}'.format(key, value) + + return rrule diff --git a/google-calendar-simple-api/build/lib/gcsa/reminders.py b/google-calendar-simple-api/build/lib/gcsa/reminders.py new file mode 100644 index 0000000000000000000000000000000000000000..9e39321bc308ae6c1b791905cd04a0d3c44ceaa0 --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/reminders.py @@ -0,0 +1,137 @@ +from datetime import time, date, datetime +from typing import Union + +from beautiful_date import BeautifulDate, days + + +class Reminder: + def __init__( + self, + method: str, + minutes_before_start: int = None, + days_before: int = None, + at: time = None + ): + """Represents base reminder object + + Provide `minutes_before_start` to create "relative" reminder. + Provide `days_before` and `at` to create "absolute" reminder. + + :param method: + Method of the reminder. Possible values: email or popup + :param minutes_before_start: + Minutes before reminder + :param days_before: + Days before reminder + :param at: + Specific time for a reminder + """ + # Nothing was provided + if minutes_before_start is None and days_before is None and at is None: + raise ValueError("Relative reminder needs 'minutes_before_start'. " + "Absolute reminder 'days_before' and 'at' set. " + "None of them were provided.") + + # Both minutes_before_start and days_before/at were provided + if minutes_before_start is not None and (days_before is not None or at is not None): + raise ValueError("Only minutes_before_start or days_before/at can be specified.") + + # Only one of days_before and at was provided + if (days_before is None) != (at is None): + raise ValueError(f'Both "days_before" and "at" values need to be set ' + f'when using absolute time for a reminder. ' + f'Provided days_before={days_before} and at={at}.') + + self.method = method + self.minutes_before_start = minutes_before_start + self.days_before = days_before + self.at = at + + def __eq__(self, other): + return ( + isinstance(other, Reminder) + and self.method == other.method + and self.minutes_before_start == other.minutes_before_start + and self.days_before == other.days_before + and self.at == other.at + ) + + def __str__(self): + if self.minutes_before_start is not None: + return '{} - minutes_before_start:{}'.format(self.__class__.__name__, self.minutes_before_start) + else: + return '{} - {} days before at {}'.format(self.__class__.__name__, self.days_before, self.at) + + def __repr__(self): + return '<{}>'.format(self.__str__()) + + def convert_to_relative(self, start: Union[date, datetime, BeautifulDate]) -> 'Reminder': + """Converts absolute reminder (with set `days_before` and `at`) to relative (with set `minutes_before_start`) + relative to `start` date/datetime. Returns self if `minutes_before_start` already set. + """ + if self.minutes_before_start is not None: + return self + + tzinfo = start.tzinfo if isinstance(start, datetime) else None + start_of_the_day = datetime.combine(start, datetime.min.time(), tzinfo=tzinfo) + + reminder_tzinfo = self.at.tzinfo or tzinfo + reminder_time = datetime.combine(start_of_the_day - self.days_before * days, self.at, tzinfo=reminder_tzinfo) + + if isinstance(start, datetime): + minutes_before_start = int((start - reminder_time).total_seconds() / 60) + else: + minutes_before_start = int((start_of_the_day - reminder_time).total_seconds() / 60) + + return Reminder( + method=self.method, + minutes_before_start=minutes_before_start + ) + + +class EmailReminder(Reminder): + def __init__( + self, + minutes_before_start: int = None, + days_before: int = None, + at: time = None + ): + """Represents email reminder object + + Provide `minutes_before_start` to create "relative" reminder. + Provide `days_before` and `at` to create "absolute" reminder. + + :param minutes_before_start: + Minutes before reminder + :param days_before: + Days before reminder + :param at: + Specific time for a reminder + """ + if not days_before and not at and not minutes_before_start: + minutes_before_start = 60 + super().__init__('email', minutes_before_start, days_before, at) + + +class PopupReminder(Reminder): + def __init__( + self, + minutes_before_start: int = None, + days_before: int = None, + at: time = None + ): + """Represents popup reminder object + + Provide `minutes_before_start` to create "relative" reminder. + Provide `days_before` and `at` to create "absolute" reminder. + + :param minutes_before_start: + Minutes before reminder + :param days_before: + Days before reminder + :param at: + Specific time for a reminder + """ + if not days_before and not at and not minutes_before_start: + minutes_before_start = 30 + super().__init__('popup', minutes_before_start, days_before, at) diff --git a/google-calendar-simple-api/build/lib/gcsa/serializers/__init__.py b/google-calendar-simple-api/build/lib/gcsa/serializers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/google-calendar-simple-api/build/lib/gcsa/serializers/acl_rule_serializer.py b/google-calendar-simple-api/build/lib/gcsa/serializers/acl_rule_serializer.py new file mode 100644 index 0000000000000000000000000000000000000000..d5dfeb4ff3588aedcc0f0c2c696a59f44b459c68 --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/serializers/acl_rule_serializer.py @@ -0,0 +1,32 @@ +from gcsa.acl import AccessControlRule +from gcsa.serializers.base_serializer import BaseSerializer + + +class ACLRuleSerializer(BaseSerializer): + type_ = AccessControlRule + + def __init__(self, access_control_rule): + super().__init__(access_control_rule) + + @staticmethod + def _to_json(acl_rule: AccessControlRule): + data = { + "id": acl_rule.id, + "scope": { + "type": acl_rule.scope_type, + "value": acl_rule.scope_value + }, + "role": acl_rule.role + } + data = ACLRuleSerializer._remove_empty_values(data) + return data + + @staticmethod + def _to_object(json_acl_rule): + scope = json_acl_rule.get('scope', {}) + return AccessControlRule( + acl_id=json_acl_rule.get('id'), + scope_type=scope.get('type'), + scope_value=scope.get('value'), + role=json_acl_rule.get('role') + ) diff --git a/google-calendar-simple-api/build/lib/gcsa/serializers/attachment_serializer.py b/google-calendar-simple-api/build/lib/gcsa/serializers/attachment_serializer.py new file mode 100644 index 0000000000000000000000000000000000000000..64f6c3689fc683fb61d28df113893da1811c9fda --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/serializers/attachment_serializer.py @@ -0,0 +1,34 @@ +from gcsa.attachment import Attachment +from .base_serializer import BaseSerializer + + +class AttachmentSerializer(BaseSerializer): + type_ = Attachment + + def __init__(self, attachment): + super().__init__(attachment) + + @staticmethod + def _to_json(attachment: Attachment): + res = { + "fileUrl": attachment.file_url, + "title": attachment.title, + "mimeType": attachment.mime_type, + } + + if attachment.file_id: + res['fileId'] = attachment.file_id + if attachment.icon_link: + res['iconLink'] = attachment.icon_link + + return res + + @staticmethod + def _to_object(json_attachment): + return Attachment( + file_url=json_attachment['fileUrl'], + title=json_attachment.get('title', None), + mime_type=json_attachment.get('mimeType', None), + _icon_link=json_attachment.get('iconLink', None), + _file_id=json_attachment.get('fileId', None) + ) diff --git a/google-calendar-simple-api/build/lib/gcsa/serializers/attendee_serializer.py b/google-calendar-simple-api/build/lib/gcsa/serializers/attendee_serializer.py new file mode 100644 index 0000000000000000000000000000000000000000..5004b18086e3907099095f3ca0ee654246e4b26b --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/serializers/attendee_serializer.py @@ -0,0 +1,34 @@ +from gcsa.attendee import Attendee +from .base_serializer import BaseSerializer + + +class AttendeeSerializer(BaseSerializer): + type_ = Attendee + + def __init__(self, attendee): + super().__init__(attendee) + + @staticmethod + def _to_json(attendee: Attendee): + data = { + 'email': attendee.email, + 'displayName': attendee.display_name, + 'comment': attendee.comment, + 'optional': attendee.optional, + 'resource': attendee.is_resource, + 'additionalGuests': attendee.additional_guests, + 'responseStatus': attendee.response_status + } + return {k: v for k, v in data.items() if v is not None} + + @staticmethod + def _to_object(json_attendee): + return Attendee( + email=json_attendee['email'], + display_name=json_attendee.get('displayName', None), + comment=json_attendee.get('comment', None), + optional=json_attendee.get('optional', None), + is_resource=json_attendee.get('resource', None), + additional_guests=json_attendee.get('additionalGuests', None), + _response_status=json_attendee.get('responseStatus', None) + ) diff --git a/google-calendar-simple-api/build/lib/gcsa/serializers/base_serializer.py b/google-calendar-simple-api/build/lib/gcsa/serializers/base_serializer.py new file mode 100644 index 0000000000000000000000000000000000000000..9883a5d5cd82c5075904f26d7264a910e4c99aea --- /dev/null +++ b/google-calendar-simple-api/build/lib/gcsa/serializers/base_serializer.py @@ -0,0 +1,81 @@ +import re +from abc import ABC, abstractmethod +import json + +import dateutil.parser + + +def _type_to_snake_case(type_): + return re.sub(r'(?NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd diff --git a/google-calendar-simple-api/docs/source/_static/css/colors.css b/google-calendar-simple-api/docs/source/_static/css/colors.css new file mode 100644 index 0000000000000000000000000000000000000000..9941d9598d367721198fb56f73f1802a7d30089e --- /dev/null +++ b/google-calendar-simple-api/docs/source/_static/css/colors.css @@ -0,0 +1,352 @@ +/* Event color */ +.lavender-classic-e { + background-color: #A4BDFC !important; + color: #000000 +} + +.lavender-modern-e { + background-color: #7986CB !important; + color: #FFFFFF +} + +.sage-classic-e { + background-color: #7AE7BF !important; + color: #000000 +} + +.sage-modern-e { + background-color: #33B679 !important; + color: #FFFFFF +} + +.grape-classic-e { + background-color: #DBADFF !important; + color: #000000 +} + +.grape-modern-e { + background-color: #8E24AA !important; + color: #FFFFFF +} + +.flamingo-classic-e { + background-color: #FF887C !important; + color: #000000 +} + +.flamingo-modern-e { + background-color: #E67C73 !important; + color: #FFFFFF +} + +.banana-classic-e { + background-color: #FBD75B !important; + color: #000000 +} + +.banana-modern-e { + background-color: #F6BF26 !important; + color: #FFFFFF +} + +.tangerine-classic-e { + background-color: #FFB878 !important; + color: #000000 +} + +.tangerine-modern-e { + background-color: #F4511E !important; + color: #FFFFFF +} + +.peacock-classic-e { + background-color: #46D6DB !important; + color: #000000 +} + +.peacock-modern-e { + background-color: #039BE5 !important; + color: #FFFFFF +} + +.graphite-classic-e { + background-color: #E1E1E1 !important; + color: #000000 +} + +.graphite-modern-e { + background-color: #616161 !important; + color: #FFFFFF +} + +.blueberry-classic-e { + background-color: #5484ED !important; + color: #000000 +} + +.blueberry-modern-e { + background-color: #3F51B5 !important; + color: #FFFFFF +} + +.basil-classic-e { + background-color: #51B749 !important; + color: #000000 +} + +.basil-modern-e { + background-color: #0B8043 !important; + color: #FFFFFF +} + +.tomato-classic-e { + background-color: #DC2127 !important; + color: #000000 +} + +.tomato-modern-e { + background-color: #D50000 !important; + color: #FFFFFF +} + + +/*Calendar colors*/ +.cocoa-classic-c { + background-color: #AC725E !important; + color: #000000 +} + +.cocoa-modern-c { + background-color: #795548 !important; + color: #FFFFFF +} + +.flamingo-classic-c { + background-color: #D06B64 !important; + color: #000000 +} + +.flamingo-modern-c { + background-color: #E67C73 !important; + color: #FFFFFF +} + +.tomato-classic-c { + background-color: #F83A22 !important; + color: #000000 +} + +.tomato-modern-c { + background-color: #D50000 !important; + color: #FFFFFF +} + +.tangerine-classic-c { + background-color: #FA573C !important; + color: #000000 +} + +.tangerine-modern-c { + background-color: #F4511E !important; + color: #FFFFFF +} + +.pumpkin-classic-c { + background-color: #FF7537 !important; + color: #000000 +} + +.pumpkin-modern-c { + background-color: #EF6C00 !important; + color: #FFFFFF +} + +.mango-classic-c { + background-color: #FFAD46 !important; + color: #000000 +} + +.mango-modern-c { + background-color: #F09300 !important; + color: #FFFFFF +} + +.eucalyptus-classic-c { + background-color: #42D692 !important; + color: #000000 +} + +.eucalyptus-modern-c { + background-color: #009688 !important; + color: #FFFFFF +} + +.basil-classic-c { + background-color: #16A765 !important; + color: #000000 +} + +.basil-modern-c { + background-color: #0B8043 !important; + color: #FFFFFF +} + +.pistachio-classic-c { + background-color: #7BD148 !important; + color: #000000 +} + +.pistachio-modern-c { + background-color: #7CB342 !important; + color: #FFFFFF +} + +.avocado-classic-c { + background-color: #B3DC6C !important; + color: #000000 +} + +.avocado-modern-c { + background-color: #C0CA33 !important; + color: #FFFFFF +} + +.citron-classic-c { + background-color: #FBE983 !important; + color: #000000 +} + +.citron-modern-c { + background-color: #E4C441 !important; + color: #FFFFFF +} + +.banana-classic-c { + background-color: #FAD165 !important; + color: #000000 +} + +.banana-modern-c { + background-color: #F6BF26 !important; + color: #FFFFFF +} + +.sage-classic-c { + background-color: #92E1C0 !important; + color: #000000 +} + +.sage-modern-c { + background-color: #33B679 !important; + color: #FFFFFF +} + +.peacock-classic-c { + background-color: #9FE1E7 !important; + color: #000000 +} + +.peacock-modern-c { + background-color: #039BE5 !important; + color: #FFFFFF +} + +.cobalt-classic-c { + background-color: #9FC6E7 !important; + color: #000000 +} + +.cobalt-modern-c { + background-color: #4285F4 !important; + color: #FFFFFF +} + +.blueberry-classic-c { + background-color: #4986E7 !important; + color: #000000 +} + +.blueberry-modern-c { + background-color: #3F51B5 !important; + color: #FFFFFF +} + +.lavender-classic-c { + background-color: #9A9CFF !important; + color: #000000 +} + +.lavender-modern-c { + background-color: #7986CB !important; + color: #FFFFFF +} + +.wisteria-classic-c { + background-color: #B99AFF !important; + color: #000000 +} + +.wisteria-modern-c { + background-color: #B39DDB !important; + color: #FFFFFF +} + +.graphite-classic-c { + background-color: #C2C2C2 !important; + color: #000000 +} + +.graphite-modern-c { + background-color: #616161 !important; + color: #FFFFFF +} + +.birch-classic-c { + background-color: #CABDBF !important; + color: #000000 +} + +.birch-modern-c { + background-color: #A79B8E !important; + color: #FFFFFF +} + +.radicchio-classic-c { + background-color: #CCA6AC !important; + color: #000000 +} + +.radicchio-modern-c { + background-color: #AD1457 !important; + color: #FFFFFF +} + +.cherry-blossom-classic-c { + background-color: #F691B2 !important; + color: #000000 +} + +.cherry-blossom-modern-c { + background-color: #D81B60 !important; + color: #FFFFFF +} + +.grape-classic-c { + background-color: #CD74E6 !important; + color: #000000 +} + +.grape-modern-c { + background-color: #8E24AA !important; + color: #FFFFFF +} + +.amethyst-classic-c { + background-color: #A47AE2 !important; + color: #000000 +} + +.amethyst-modern-c { + background-color: #9E69AF !important; + color: #FFFFFF +} \ No newline at end of file diff --git a/google-calendar-simple-api/docs/source/_static/css/custom.css b/google-calendar-simple-api/docs/source/_static/css/custom.css new file mode 100644 index 0000000000000000000000000000000000000000..28839e07271d506e1b05b2d018c7a7f6a171caaf --- /dev/null +++ b/google-calendar-simple-api/docs/source/_static/css/custom.css @@ -0,0 +1,16 @@ +/* Newlines (\a) and spaces (\20) before each parameter */ +.sig-param::before { + content: "\a\20\20\20\20\20\20\20\20\20\20\20\20\20\20\20\20"; + white-space: pre; +} + +/* Newline after the last parameter (so the closing bracket is on a new line) */ +dt em.sig-param:last-of-type::after { + content: "\a"; + white-space: pre; +} + +/* To have blue background of width of the block (instead of width of content) */ +dl.class > dt:first-of-type { + display: block !important; +} \ No newline at end of file diff --git a/google-calendar-simple-api/docs/source/_static/push_ups.webp b/google-calendar-simple-api/docs/source/_static/push_ups.webp new file mode 100644 index 0000000000000000000000000000000000000000..907092c981023f8b86d900d762df4e59e089dde7 Binary files /dev/null and b/google-calendar-simple-api/docs/source/_static/push_ups.webp differ diff --git a/google-calendar-simple-api/docs/source/_templates/footer.html b/google-calendar-simple-api/docs/source/_templates/footer.html new file mode 100644 index 0000000000000000000000000000000000000000..34cdecab904773a0e185b3715be42475e2ac6e22 --- /dev/null +++ b/google-calendar-simple-api/docs/source/_templates/footer.html @@ -0,0 +1,10 @@ +{% extends "!footer.html" %} +{% block extrafooter %}{{ super() }} +
+
+ Portions of this page are reproduced from and/or are modifications based on work created and + shared by Google + and used according to terms described in the + Creative Commons 4.0 Attribution License. +
+{% endblock %} diff --git a/google-calendar-simple-api/docs/source/_templates/layout.html b/google-calendar-simple-api/docs/source/_templates/layout.html new file mode 100644 index 0000000000000000000000000000000000000000..462e82197116341f7a3bda614d4d5b3bff5740f4 --- /dev/null +++ b/google-calendar-simple-api/docs/source/_templates/layout.html @@ -0,0 +1,10 @@ +{% extends "!layout.html" %} +{% block sidebartitle %}{{ super() }} +
+ Star +
+{% endblock %} +{% block footer %}{{ super() }} + +{% endblock %} \ No newline at end of file diff --git a/google-calendar-simple-api/docs/source/acl.rst b/google-calendar-simple-api/docs/source/acl.rst new file mode 100644 index 0000000000000000000000000000000000000000..548b78eb60ffbeffbe63d438ad06a6ef1c3a872b --- /dev/null +++ b/google-calendar-simple-api/docs/source/acl.rst @@ -0,0 +1,76 @@ +.. _acl: + +Access Control List +=================== + +Access control rule is represented by the class :py:class:`~gcsa.acl.AccessControlRule`. + +`gcsa` allows you to add a new access control rule, retrieve, update and delete existing rules. + + +To do so, create a :py:class:`~gcsa.google_calendar.GoogleCalendar` instance (see :ref:`getting_started` to get your +credentials): + +.. code-block:: python + + from gcsa.google_calendar import GoogleCalendar + + gc = GoogleCalendar() + + +List rules +~~~~~~~~~~ + +.. code-block:: python + + for rule in gc.get_acl_rules(): + print(rule) + + +Get rule by id +~~~~~~~~~~~~~~ + +.. code-block:: python + + rule = gc.get_acl_rule(rule_id='') + print(rule) + + +Add access rule +~~~~~~~~~~~~~~~ + +To add a new ACL rule, create an :py:class:`~gcsa.acl.AccessControlRule` object with specified role +(see more in :py:class:`~gcsa.acl.ACLRole`), scope type (see more in :py:class:`~gcsa.acl.ACLScopeType`), and scope +value. + +.. code-block:: python + + from gcsa.acl import AccessControlRule, ACLRole, ACLScopeType + + rule = AccessControlRule( + role=ACLRole.READER, + scope_type=ACLScopeType.USER, + scope_value='friend@gmail.com', + ) + + rule = gc.add_acl_rule(rule) + print(rule.id) + + +Update access rule +~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + rule = gc.get_acl_rule('') + rule.role = ACLRole.WRITER + rule = gc.update_acl_rule(rule) + + +Delete access rule +~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + rule = gc.get_acl_rule('') + gc.delete_acl_rule(rule) diff --git a/google-calendar-simple-api/docs/source/attachments.rst b/google-calendar-simple-api/docs/source/attachments.rst new file mode 100644 index 0000000000000000000000000000000000000000..29776c44ad526268e3c4d9d136388ae0e5512a1a --- /dev/null +++ b/google-calendar-simple-api/docs/source/attachments.rst @@ -0,0 +1,39 @@ +.. _attachments: + +Attachments +----------- + +If you want to add attachment(s) to your event, just create :py:class:`~gcsa.attachment.Attachment` (s) and pass +as a ``attachments`` parameter: + +.. code-block:: python + + from gcsa.attachment import Attachment + + attachment = Attachment(file_url='https://bit.ly/3lZo0Cc', + title='My file', + mime_type='application/vnd.google-apps.document') + + event = Event('Meeting', + start=(22/Apr/2019)[12:00], + attachments=attachment) + + +You can pass multiple attachments at once in a list. + +.. code-block:: python + + event = Event('Meeting', + start=(22/Apr/2019)[12:00], + attachments=[attachment1, attachment2]) + +To add attachment to an existing event use its :py:meth:`~gcsa.event.Event.add_attachment` method: + + +.. code-block:: python + + event.add_attachment('My file', + file_url='https://bit.ly/3lZo0Cc', + mime_type='application/vnd.google-apps.document') + +Update event using :py:meth:`~gcsa.google_calendar.GoogleCalendar.update_event` method to save the changes. diff --git a/google-calendar-simple-api/docs/source/attendees.rst b/google-calendar-simple-api/docs/source/attendees.rst new file mode 100644 index 0000000000000000000000000000000000000000..168ff383fb4419ca40e43c98b2d17c303ec01c12 --- /dev/null +++ b/google-calendar-simple-api/docs/source/attendees.rst @@ -0,0 +1,81 @@ +.. _attendees: + +Attendees +========= + +If you want to add attendee(s) to your event, just create :py:class:`~gcsa.attendee.Attendee` (s) and pass +as an ``attendees`` parameter (you can also pass just an email of the attendee and +the :py:class:`~gcsa.attendee.Attendee` will be created for you): + +.. code-block:: python + + from gcsa.attendee import Attendee + + attendee = Attendee( + 'attendee@gmail.com', + display_name='Friend', + additional_guests=3 + ) + + event = Event('Meeting', + start=(17/Jul/2020)[12:00], + attendees=attendee) + +or + +.. code-block:: python + + event = Event('Meeting', + start=(17/Jul/2020)[12:00], + attendees='attendee@gmail.com') + +You can pass multiple attendees at once in a list. + + +.. code-block:: python + + event = Event('Meeting', + start=(17/Jul/2020)[12:00], + attendees=[ + 'attendee@gmail.com', + Attendee('attendee2@gmail.com', display_name='Friend') + ]) + +To **notify** attendees about created/updated/deleted event use `send_updates` parameter in `add_event`, `update_event`, +and `delete_event` methods. See :py:class:`~gcsa.google_calendar.SendUpdatesMode` for possible values. + +To add attendees to an existing event use its :py:meth:`~gcsa.event.Event.add_attendee` method: + +.. code-block:: python + + event.add_attendee( + Attendee('attendee@gmail.com', + display_name='Friend', + additional_guests=3 + ) + ) + +or + +.. code-block:: python + + event.add_attendee('attendee@gmail.com') + +to add a single attendee. + +Use :py:meth:`~gcsa.event.Event.add_attendees` method to add multiple at once: + +.. code-block:: python + + event.add_attendees( + [ + Attendee('attendee@gmail.com', + display_name='Friend', + additional_guests=3 + ), + 'attendee_by_email1@gmail.com', + 'attendee_by_email2@gmail.com' + ] + ) + +Update event using :py:meth:`~gcsa.google_calendar.GoogleCalendar.update_event` method to save the changes. diff --git a/google-calendar-simple-api/docs/source/authentication.rst b/google-calendar-simple-api/docs/source/authentication.rst new file mode 100644 index 0000000000000000000000000000000000000000..3e66a22a404e50a33f388035c547286d551d1368 --- /dev/null +++ b/google-calendar-simple-api/docs/source/authentication.rst @@ -0,0 +1,122 @@ +.. _authentication: + +Authentication +============== + +There are several ways to authenticate in ``GoogleCalendar``. + +Credentials file +---------------- + +If you have a ``credentials.json`` (``client_secret_*.json``) file (see :ref:`getting_started`), ``GoogleCalendar`` +will read all the needed data to generate the token and refresh-token from it. + +To read ``credentials.json`` (``client_secret_*.json``) from the default directory (``~/.credentials``) use: + +.. code-block:: python + + gc = GoogleCalendar() + +In this case, if ``~/.credentials/token.pickle`` file exists, it will read it and refresh only if needed. If +``token.pickle`` does not exist, it will be created during authentication flow and saved alongside with +``credentials.json`` (``client_secret_*.json``) in ``~/.credentials/token.pickle``. + +To **avoid saving** the token use: + +.. code-block:: python + + gc = GoogleCalendar(save_token=False) + +After token is generated during authentication flow, it can be accessed in ``gc.credentials`` field. + +To specify ``credentials.json`` (``client_secret_*.json``) file path use ``credentials_path`` parameter: + +.. code-block:: python + + gc = GoogleCalendar(credentials_path='path/to/credentials.json') + +or + +.. code-block:: python + + gc = GoogleCalendar(credentials_path='path/to/client_secret_273833015691-qwerty.apps.googleusercontent.com.json') + +Similarly, if ``token.pickle`` file exists in the same folder (``path/to/``), it will be used and refreshed only if +needed. If it doesn't exist, it will be generated and stored alongside the ``credentials.json`` (``client_secret_*.json``) +(in ``path/to/token.pickle``). + +To specify different path for the pickled token file use ``token_path`` parameter: + +.. code-block:: python + + gc = GoogleCalendar(credentials_path='path/to/credentials.json', + token_path='another/path/user1_token.pickle') + +That could be useful if you want to save the file elsewhere, or if you have multiple google accounts. + +Token object +------------ + +If you store/receive/generate the token in a different way, you can pass loaded token directly: + +.. code-block:: python + + from google.oauth2.credentials import Credentials + + token = Credentials( + token='', + refresh_token='', + client_id='', + client_secret='', + scopes=['https://www.googleapis.com/auth/calendar'], + token_uri='https://oauth2.googleapis.com/token' + ) + gc = GoogleCalendar(credentials=token) + +It will be refreshed using ``refresh_token`` during initialization of ``GoogleCalendar`` if needed. + + +Multiple calendars +------------------ +To authenticate multiple Google Calendars you should specify different `token_path` for each of them. Otherwise, +`gcsa` would overwrite default token file location: + +.. code-block:: python + + gc_primary = GoogleCalendar(token_path='path/to/tokens/token_primary.pickle') + gc_secondary = GoogleCalendar(calendar='f7c1gf7av3g6f2dave17gan4b8@group.calendar.google.com', + token_path='path/to/tokens/token_secondary.pickle') + + +Browser authentication timeout +------------------------------ + +If you'd like to avoid your script hanging in case user closes the browser without finishing authentication flow, +you can use the following solution with the help of Pebble_. + +First, install `Pebble` with ``pip install pebble``. + +.. code-block:: python + + from gcsa.google_calendar import GoogleCalendar + from concurrent.futures import TimeoutError + from pebble import concurrent + + + @concurrent.process(timeout=60) + def create_process(): + return GoogleCalendar() + + + if __name__ == '__main__': + try: + process = create_process() + gc = process.result() + except TimeoutError: + print("User hasn't authenticated in 60 seconds") + +Thanks to Teraskull_ for the idea and the example. + +.. _Pebble: https://pypi.org/project/Pebble/ +.. _Teraskull: https://github.com/Teraskull + diff --git a/google-calendar-simple-api/docs/source/calendars.rst b/google-calendar-simple-api/docs/source/calendars.rst new file mode 100644 index 0000000000000000000000000000000000000000..5e2f9c05c9e6f5538d7bde517d9e5daae5059166 --- /dev/null +++ b/google-calendar-simple-api/docs/source/calendars.rst @@ -0,0 +1,229 @@ +.. _calendars: + +Calendars and Calendar list +============================ + +Calendars in `gcsa` are represented by the :py:class:`~gcsa.calendar.Calendar` and +:py:class:`~gcsa.calendar.CalendarListEntry` classes. + + +Calendars vs Calendar List +~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The **Calendars** collection represents all existing calendars. It can be used to create and delete calendars. You can also +retrieve or set global properties shared across all users with access to a calendar. For example, a calendar's title and +default time zone are global properties. + +The **Calendar List** is a collection of all calendar entries that a user has added to their list (shown in the left panel +of the web UI). You can use it to add and remove existing calendars to/from the users’ list. You also use it to retrieve +and set the values of user-specific calendar properties, such as default reminders. Another example is foreground color, +since different users can have different colors set for the same calendar. + +The **GoogleCalendar** is a service responsible for processing API requests. + +Calendars +~~~~~~~~~ + +`gcsa` allows you to create a new calendar, retrieve, update, delete and clear existing calendars. + +To do so, create a :py:class:`~gcsa.google_calendar.GoogleCalendar` instance (see :ref:`getting_started` to get your +credentials): + +.. code-block:: python + + from gcsa.google_calendar import GoogleCalendar + + gc = GoogleCalendar() + + +Get calendar by id +------------------ +This returns an objects that stores metadata of the calendar (see more in :py:class:`~gcsa.calendar.Calendar`). + +Get a calendar specified as a default in `GoogleCalendar()` + +.. code-block:: python + + calendar = gc.get_calendar() + +To get a calendar other than the one specified as a default in `GoogleCalendar()` + +.. code-block:: python + + calendar = gc.get_calendar('') + + +Add a secondary calendar +------------------------ + +.. code-block:: python + + from gcsa.calendar import Calendar + + calendar = Calendar( + 'Travel calendar', + description='Calendar for travel related events' + ) + calendar = gc.add_calendar(calendar) + + +Update calendar +--------------- + +.. code-block:: python + + calendar.summary = 'New summary' + calendar.description = 'New description' + calendar = gc.update_calendar(calendar) + + +Delete calendar +--------------- + +.. code-block:: python + + gc.delete_calendar(calendar) + +or by id + +.. code-block:: python + + gc.delete_calendar('') + + +Calendar has to have ``calendar_id`` to be updated or deleted. Calendars that you get from +:py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list` method already have their ids. +You can also delete the calendar by providing its id. + + +Clear calendar +-------------- + +You can only clear (remove all events from) **primary** calendar. + +.. code-block:: python + + gc.clear_calendar() + +.. warning:: + This will always try to clear a **primary** calendar, regardless of the `default_calendar` value. + + +Calendar List +~~~~~~~~~~~~~ + +`gcsa` allows you to add an existing calendar into the user's calendar list, retrieve user's calendar list, +retrieve, update, and delete single entries in the user's calendar list. + +To do so, create a :py:class:`~gcsa.google_calendar.GoogleCalendar` instance (see :ref:`getting_started` to get your +credentials): + +.. code-block:: python + + from gcsa.google_calendar import GoogleCalendar + + gc = GoogleCalendar() + + +Get user's calendar list +------------------------ + +This returns the collection of calendars in the user's calendar list. +(see more in :py:class:`~gcsa.calendar.CalendarListEntry`). + +.. code-block:: python + + for calendar in gc.get_calendar_list(): + print(calendar) + +you can include deleted and hidden entries and specify the minimal access role: + +.. code-block:: python + + from gcsa.calendar import AccessRoles + + gc.get_calendar_list( + min_access_role=AccessRoles.READER + show_deleted=True, + show_hidden=True + ) + +Get calendar list entry by id +----------------------------- + +Get a calendar specified as a default in `GoogleCalendar()` + +.. code-block:: python + + gc.get_calendar_list_entry() + + +To get calendar other then the one specified as a default in `GoogleCalendar()` + + +.. code-block:: python + + gc.get_calendar_list_entry('calendar_id') + + +Add calendar list entry +----------------------- + +This adds an existing calendar into the user's calendar list +(see more in :py:class:`~gcsa.calendar.CalendarListEntry`): + + +.. code-block:: python + + from gcsa.calendar import CalendarListEntry + + calendar_list_entry = CalendarListEntry( + calendar_id='calendar_id', + summary_override='Holidays in Czechia' + ) + gc.add_calendar_list_entry(calendar_list_entry) + +You can make a :py:class:`~gcsa.calendar.CalendarListEntry` out of :py:class:`~gcsa.calendar.Calendar` with +:py:meth:`~gcsa.calendar.Calendar.to_calendar_list_entry` method: + + +.. code-block:: python + + calendar = Calendar( + calendar_id='calendar_id', + summary='StΓ‘tnΓ­ svΓ‘tky v ČR' + ) + calendar_list_entry = calendar.to_calendar_list_entry( + summary_override='Holidays in Czechia' + ) + gc.add_calendar_list_entry(calendar_list_entry) + + +Update calendar list entry +-------------------------- + +.. code-block:: python + + calendar_list_entry.summary_override = 'Holidays in Czechia 2022' + gc.update_calendar_list_entry(calendar_list_entry) + +Delete calendar list entry +-------------------------- + +You can use :py:class:`~gcsa.calendar.CalendarListEntry`, :py:class:`~gcsa.calendar.Calendar`, or calendar id: + +.. code-block:: python + + gc.delete_calendar_list_entry(calendar) + +or + +.. code-block:: python + + gc.delete_calendar_list_entry(calendar_list_entry) + +or + +.. code-block:: python + + gc.delete_calendar_list_entry('') + diff --git a/google-calendar-simple-api/docs/source/change_log.rst b/google-calendar-simple-api/docs/source/change_log.rst new file mode 100644 index 0000000000000000000000000000000000000000..ce5774c877b3080cc211ce34c8357a48350308e7 --- /dev/null +++ b/google-calendar-simple-api/docs/source/change_log.rst @@ -0,0 +1,194 @@ +.. _change_log: + +Change log +========== + +v2.3.0 +~~~~~~ + +API +--- +* Adds `add_attendees` method to the `Event` for adding multiple attendees +* Add specific time reminders (N days before at HH:MM) +* Support Python3.12 +* Allow service account credentials in `GoogleCalendar` + +Core +---- +* Don't evaluate default arguments in code docs (primarily for `timezone=get_localzone_name()`) + +Backward compatibility +---------------------- +* If token is expired but doesn't have refresh token, raises `google.auth.exceptions.RefreshError` + instead of sending the request + + +v2.2.0 +~~~~~~ + +API +--- +* Adds support for new credentials file names (i.e. client_secret_*.json) + +Core +---- +* None + +Backward compatibility +---------------------- +* Full compatibility + + +v2.1.0 +~~~~~~ + +API +--- +* Adds support for python3.11 +* Adds support for access control list (ACL) management +* Fix converting date to datetime in get_events +* Adds support for free/busy requests + +Core +---- +* None + +Backward compatibility +---------------------- +* Full compatibility + +v2.0.1 +~~~~~~ + +API +--- +* Fixes issue with unknown timezones + +Core +---- +* Removes pytz dependency + +Backward compatibility +---------------------- +* Full compatibility + + +v2.0.0 +~~~~~~ + +API +--- +* Adds calendar and calendar list related methods +* Adds settings related method +* Adds colors related method +* Adds support for python3.10 + +Core +---- +* Separates ``GoogleCalendar`` into authentication, events, calendars, calendar list, colors, and settings services +* Uses newest documentation generation libraries + +Backward compatibility +---------------------- +* Full compatibility + + +v1.3.0 +~~~~~~ + +API +--- +* Adds deletion of event by its id in ``GoogleCalendar.delete_event()`` + +Core +---- +* None + +Backward compatibility +---------------------- +* Full compatibility + + +v1.2.1 +~~~~~~ + +API +--- +* Adds ``Event.id`` in serialized event +* Fixes conference's entry point without ``entry_point_type`` + +Core +---- +* Switches to tox for testing + +Backward compatibility +---------------------- +* Full compatibility + + +v1.2.0 +~~~~~~ + +API +--- +* Adds ``GoogleCalendar.import_event()`` method + +Core +---- +* None + +Backward compatibility +---------------------- +* Full compatibility + + +v1.1.0 +~~~~~~ + +API +--- +* Fixes event creation without ``start`` and ``end`` +* Adds ``creator``, ``organizer`` and ``transparency`` fields to event + +Core +---- +* None + +Backward compatibility +---------------------- +* Full compatibility + + +v1.0.1 +~~~~~~ + +API +--- +* Fixes ``GoogleCalendar.clear()`` method + +Core +---- +* None + +Backward compatibility +---------------------- +* Full compatibility + + +v1.0.0 and previous versions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +API +--- +* Adds authentication management +* Adds event management +* Adds documentation in readthedocs.com + +Core +---- +* Adds serializers for events and related objects +* Adds automated testing in GitHub actions with code-coverage + +Backward compatibility +---------------------- +* Full compatibility \ No newline at end of file diff --git a/google-calendar-simple-api/docs/source/code/acl.rst b/google-calendar-simple-api/docs/source/code/acl.rst new file mode 100644 index 0000000000000000000000000000000000000000..8b55d361aca21b582ef4891145b4a2f654c1563a --- /dev/null +++ b/google-calendar-simple-api/docs/source/code/acl.rst @@ -0,0 +1,15 @@ +Access control list +=================== + + +.. autoclass:: gcsa.acl.AccessControlRule + :members: + :undoc-members: + +.. autoclass:: gcsa.acl.ACLRole + :members: + :undoc-members: + +.. autoclass:: gcsa.acl.ACLScopeType + :members: + :undoc-members: diff --git a/google-calendar-simple-api/docs/source/code/attachment.rst b/google-calendar-simple-api/docs/source/code/attachment.rst new file mode 100644 index 0000000000000000000000000000000000000000..6d989edc10462f551923a4c55419647096c51462 --- /dev/null +++ b/google-calendar-simple-api/docs/source/code/attachment.rst @@ -0,0 +1,7 @@ +Attachments +=========== + + +.. autoclass:: gcsa.attachment.Attachment + :members: + :undoc-members: diff --git a/google-calendar-simple-api/docs/source/code/attendees.rst b/google-calendar-simple-api/docs/source/code/attendees.rst new file mode 100644 index 0000000000000000000000000000000000000000..7cc01200eac922ede77981e7c3c22c046a4250ce --- /dev/null +++ b/google-calendar-simple-api/docs/source/code/attendees.rst @@ -0,0 +1,11 @@ +Attendees +========= + + +.. autoclass:: gcsa.attendee.Attendee + :members: + :undoc-members: + +.. autoclass:: gcsa.attendee.ResponseStatus + :members: + :undoc-members: diff --git a/google-calendar-simple-api/docs/source/code/calendar.rst b/google-calendar-simple-api/docs/source/code/calendar.rst new file mode 100644 index 0000000000000000000000000000000000000000..48b2c27207132119c18845d6a807ae088018a0be --- /dev/null +++ b/google-calendar-simple-api/docs/source/code/calendar.rst @@ -0,0 +1,20 @@ +Calendar +======== + + +.. autoclass:: gcsa.calendar.Calendar + :members: + :undoc-members: + + +.. autoclass:: gcsa.calendar.CalendarListEntry + :members: + :undoc-members: + +.. autoclass:: gcsa.calendar.NotificationType + :members: + :undoc-members: + +.. autoclass:: gcsa.calendar.AccessRoles + :members: + :undoc-members: diff --git a/google-calendar-simple-api/docs/source/code/code.rst b/google-calendar-simple-api/docs/source/code/code.rst new file mode 100644 index 0000000000000000000000000000000000000000..8db2f43adcdfad5c0ca17a7da46c282ec0b1cde3 --- /dev/null +++ b/google-calendar-simple-api/docs/source/code/code.rst @@ -0,0 +1,19 @@ +Code documentation +================== + +.. toctree:: + :maxdepth: 3 + :caption: Contents: + + google_calendar + calendar + event + person + attendees + attachment + conference + reminders + recurrence + acl + free_busy + settings diff --git a/google-calendar-simple-api/docs/source/code/conference.rst b/google-calendar-simple-api/docs/source/code/conference.rst new file mode 100644 index 0000000000000000000000000000000000000000..81b65369580b907e6c80f4b99412ee3c0dacd982 --- /dev/null +++ b/google-calendar-simple-api/docs/source/code/conference.rst @@ -0,0 +1,19 @@ +Conference +========== + + +.. autoclass:: gcsa.conference.ConferenceSolution + :members: + :undoc-members: + +.. autoclass:: gcsa.conference.EntryPoint + :members: + :undoc-members: + +.. autoclass:: gcsa.conference.ConferenceSolutionCreateRequest + :members: + :undoc-members: + +.. autoclass:: gcsa.conference.SolutionType + :members: + :undoc-members: \ No newline at end of file diff --git a/google-calendar-simple-api/docs/source/code/event.rst b/google-calendar-simple-api/docs/source/code/event.rst new file mode 100644 index 0000000000000000000000000000000000000000..9f828c8ac295b6e826ba06f3dda86d26c03088b6 --- /dev/null +++ b/google-calendar-simple-api/docs/source/code/event.rst @@ -0,0 +1,15 @@ +Event +===== + + +.. autoclass:: gcsa.event.Event + :members: + :undoc-members: + +.. autoclass:: gcsa.event.Visibility + :members: + :undoc-members: + +.. autoclass:: gcsa.event.Transparency + :members: + :undoc-members: diff --git a/google-calendar-simple-api/docs/source/code/free_busy.rst b/google-calendar-simple-api/docs/source/code/free_busy.rst new file mode 100644 index 0000000000000000000000000000000000000000..09081b8d80b6e4e5bbbf505df0f4ba11ee0d1ebf --- /dev/null +++ b/google-calendar-simple-api/docs/source/code/free_busy.rst @@ -0,0 +1,12 @@ +Free busy +========= + + +.. autoclass:: gcsa.free_busy.FreeBusy + :members: + :undoc-members: + + +.. autoclass:: gcsa.free_busy.FreeBusyQueryError + :members: + :undoc-members: diff --git a/google-calendar-simple-api/docs/source/code/google_calendar.rst b/google-calendar-simple-api/docs/source/code/google_calendar.rst new file mode 100644 index 0000000000000000000000000000000000000000..34557db92f1354dfb78cd4a1200675686b833a5f --- /dev/null +++ b/google-calendar-simple-api/docs/source/code/google_calendar.rst @@ -0,0 +1,39 @@ +GoogleCalendar +============== + +.. autoclass:: gcsa.google_calendar.GoogleCalendar + :members: + get_events, + get_instances, + get_event, + add_event, + add_quick_event, + update_event, + import_event, + move_event, + delete_event, + get_calendar, + add_calendar, + update_calendar, + delete_calendar, + clear_calendar, + clear, + get_calendar_list, + get_calendar_list_entry, + add_calendar_list_entry, + update_calendar_list_entry, + delete_calendar_list_entry, + list_event_colors, + list_calendar_colors, + get_acl_rules, + get_acl_rule, + add_acl_rule, + update_acl_rule, + delete_acl_rule, + get_free_busy, + get_settings + :undoc-members: + +.. autoclass:: gcsa.google_calendar.SendUpdatesMode + :members: + :undoc-members: diff --git a/google-calendar-simple-api/docs/source/code/person.rst b/google-calendar-simple-api/docs/source/code/person.rst new file mode 100644 index 0000000000000000000000000000000000000000..beaac7c2e84e7d2fff25223e3f4ae4f30e3981cc --- /dev/null +++ b/google-calendar-simple-api/docs/source/code/person.rst @@ -0,0 +1,7 @@ +Person +====== + + +.. autoclass:: gcsa.person.Person + :members: + :undoc-members: diff --git a/google-calendar-simple-api/docs/source/code/recurrence.rst b/google-calendar-simple-api/docs/source/code/recurrence.rst new file mode 100644 index 0000000000000000000000000000000000000000..153f2c07e8a7a01bed2380cfc64e3abd79fdf4bf --- /dev/null +++ b/google-calendar-simple-api/docs/source/code/recurrence.rst @@ -0,0 +1,8 @@ +Recurrence +========== + + +.. automodule:: gcsa.recurrence + :members: + :undoc-members: + :member-order: bysource diff --git a/google-calendar-simple-api/docs/source/code/reminders.rst b/google-calendar-simple-api/docs/source/code/reminders.rst new file mode 100644 index 0000000000000000000000000000000000000000..45ea0d8032308f0a4df6612c9400f6e0652305b4 --- /dev/null +++ b/google-calendar-simple-api/docs/source/code/reminders.rst @@ -0,0 +1,7 @@ +Reminders +========= + + +.. automodule:: gcsa.reminders + :members: + :undoc-members: diff --git a/google-calendar-simple-api/docs/source/code/settings.rst b/google-calendar-simple-api/docs/source/code/settings.rst new file mode 100644 index 0000000000000000000000000000000000000000..429caaae71cf4d71da4abdbc4c2764abe0fc2ded --- /dev/null +++ b/google-calendar-simple-api/docs/source/code/settings.rst @@ -0,0 +1,7 @@ +Settings +======== + + +.. autoclass:: gcsa.settings.Settings + :members: + :undoc-members: diff --git a/google-calendar-simple-api/docs/source/colors.rst b/google-calendar-simple-api/docs/source/colors.rst new file mode 100644 index 0000000000000000000000000000000000000000..1f2f04675dae4b78b7abbe6413a05382e24442d3 --- /dev/null +++ b/google-calendar-simple-api/docs/source/colors.rst @@ -0,0 +1,287 @@ +.. _colors: + +Colors +====== + +`gcsa` allows you to retrieve a list of available calendar and event colors with their ids. + +To do so, create a :py:class:`~gcsa.google_calendar.GoogleCalendar` instance (see :ref:`getting_started` to get your +credentials): + +.. code-block:: python + + from gcsa.google_calendar import GoogleCalendar + + gc = GoogleCalendar() + +.. note:: | Google's API always returns "classic" colors. + | They are always the same, so you can just use IDs from the tables bellow. + | You can choose between "classic" and "modern" color sets in the UI. Color names and IDs are the same, + but the colors are different (see the tables bellow). + +Event colors +~~~~~~~~~~~~ + +List event colors +----------------- + +.. code-block:: python + + for color_id, color in gc.list_event_colors().items(): + bg = color['background'] + fg = color['foreground'] + print(color_id, f'{bg=}, {fg=}') + +.. CSS classes +.. role:: lavender-classic-e +.. role:: lavender-modern-e +.. role:: sage-classic-e +.. role:: sage-modern-e +.. role:: grape-classic-e +.. role:: grape-modern-e +.. role:: flamingo-classic-e +.. role:: flamingo-modern-e +.. role:: banana-classic-e +.. role:: banana-modern-e +.. role:: tangerine-classic-e +.. role:: tangerine-modern-e +.. role:: peacock-classic-e +.. role:: peacock-modern-e +.. role:: graphite-classic-e +.. role:: graphite-modern-e +.. role:: blueberry-classic-e +.. role:: blueberry-modern-e +.. role:: basil-classic-e +.. role:: basil-modern-e +.. role:: tomato-classic-e +.. role:: tomato-modern-e + + + +.. table:: Event colors + :widths: 1 1 1 1 + + +----------+------------------+-------------------------------------+-------------------------------------+ + | Color ID | Name | Classic | Modern | + +==========+==================+=====================================+=====================================+ + | '1' | Lavender | :lavender-classic-e:`#A4BDFC` | :lavender-modern-e:`#7986CB` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '2' | Sage | :sage-classic-e:`#7AE7BF` | :sage-modern-e:`#33B679` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '3' | Grape | :grape-classic-e:`#DBADFF` | :grape-modern-e:`#8E24AA` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '4' | Flamingo | :flamingo-classic-e:`#FF887C` | :flamingo-modern-e:`#E67C73` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '5' | Banana | :banana-classic-e:`#FBD75B` | :banana-modern-e:`#F6BF26` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '6' | Tangerine | :tangerine-classic-e:`#FFB878` | :tangerine-modern-e:`#F4511E` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '7' | Peacock | :peacock-classic-e:`#46D6DB` | :peacock-modern-e:`#039BE5` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '8' | Graphite | :graphite-classic-e:`#E1E1E1` | :graphite-modern-e:`#616161` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '9' | Blueberry | :blueberry-classic-e:`#5484ED` | :blueberry-modern-e:`#3F51B5` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '10' | Basil | :basil-classic-e:`#51B749` | :basil-modern-e:`#0B8043` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '11' | Tomato | :tomato-classic-e:`#DC2127` | :tomato-modern-e:`#D50000` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | None | Color of the calendar | + +----------+------------------+-------------------------------------+-------------------------------------+ + + +Set event color +--------------- + +Use color ID in :py:class:`~gcsa.event.Event`'s `color_id` field: + +.. code-block:: python + + from gcsa.event import Event + + FLAMINGO_COLOR_ID = '4' + event = Event('Important!', + start=start, + color_id=FLAMINGO_COLOR_ID) + event = gc.add_event(event) + + +Update event color +------------------ + +.. code-block:: python + + FLAMINGO_COLOR_ID = '4' + event.color_id = FLAMINGO_COLOR_ID + gc.update_event(event) + + + +Calendar colors +~~~~~~~~~~~~~~~ + +.. note:: Color is a property of a calendar list entry, not a calendar itself (see the difference in :ref:`calendars`). + Unlike events' colors, you can use either the color ID or hex values to set a color for a calendar list entry. + If you use hex values (they are not limited to values from the table bellow), value of the color ID will be + set automatically to the best matching option. + +List calendar colors +-------------------- + +.. code-block:: python + + for color_id, color in gc.list_calendar_colors().items(): + bg = color['background'] + fg = color['foreground'] + print(color_id, f'{bg=}, {fg=}') + +.. CSS classes +.. role:: cocoa-classic-c +.. role:: cocoa-modern-c +.. role:: flamingo-classic-c +.. role:: flamingo-modern-c +.. role:: tomato-classic-c +.. role:: tomato-modern-c +.. role:: tangerine-classic-c +.. role:: tangerine-modern-c +.. role:: pumpkin-classic-c +.. role:: pumpkin-modern-c +.. role:: mango-classic-c +.. role:: mango-modern-c +.. role:: eucalyptus-classic-c +.. role:: eucalyptus-modern-c +.. role:: basil-classic-c +.. role:: basil-modern-c +.. role:: pistachio-classic-c +.. role:: pistachio-modern-c +.. role:: avocado-classic-c +.. role:: avocado-modern-c +.. role:: citron-classic-c +.. role:: citron-modern-c +.. role:: banana-classic-c +.. role:: banana-modern-c +.. role:: sage-classic-c +.. role:: sage-modern-c +.. role:: peacock-classic-c +.. role:: peacock-modern-c +.. role:: cobalt-classic-c +.. role:: cobalt-modern-c +.. role:: blueberry-classic-c +.. role:: blueberry-modern-c +.. role:: lavender-classic-c +.. role:: lavender-modern-c +.. role:: wisteria-classic-c +.. role:: wisteria-modern-c +.. role:: graphite-classic-c +.. role:: graphite-modern-c +.. role:: birch-classic-c +.. role:: birch-modern-c +.. role:: radicchio-classic-c +.. role:: radicchio-modern-c +.. role:: cherry-blossom-classic-c +.. role:: cherry-blossom-modern-c +.. role:: grape-classic-c +.. role:: grape-modern-c +.. role:: amethyst-classic-c +.. role:: amethyst-modern-c + +.. table:: Calendar colors + :widths: 1 1 1 1 + + +----------+------------------+-------------------------------------+-------------------------------------+ + | Color ID | Name | Classic | Modern | + +==========+==================+=====================================+=====================================+ + | '1' | Cocoa | :cocoa-classic-c:`#AC725E` | :cocoa-modern-c:`#795548` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '2' | Flamingo | :flamingo-classic-c:`#D06B64` | :flamingo-modern-c:`#E67C73` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '3' | Tomato | :tomato-classic-c:`#F83A22` | :tomato-modern-c:`#D50000` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '4' | Tangerine | :tangerine-classic-c:`#FA573C` | :tangerine-modern-c:`#F4511E` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '5' | Pumpkin | :pumpkin-classic-c:`#FF7537` | :pumpkin-modern-c:`#EF6C00` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '6' | Mango | :mango-classic-c:`#FFAD46` | :mango-modern-c:`#F09300` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '7' | Eucalyptus | :eucalyptus-classic-c:`#42D692` | :eucalyptus-modern-c:`#009688` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '8' | Basil | :basil-classic-c:`#16A765` | :basil-modern-c:`#0B8043` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '9' | Pistachio | :pistachio-classic-c:`#7BD148` | :pistachio-modern-c:`#7CB342` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '10' | Avocado | :avocado-classic-c:`#B3DC6C` | :avocado-modern-c:`#C0CA33` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '11' | Citron | :citron-classic-c:`#FBE983` | :citron-modern-c:`#E4C441` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '12' | Banana | :banana-classic-c:`#FAD165` | :banana-modern-c:`#F6BF26` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '13' | Sage | :sage-classic-c:`#92E1C0` | :sage-modern-c:`#33B679` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '14' | Peacock | :peacock-classic-c:`#9FE1E7` | :peacock-modern-c:`#039BE5` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '15' | Cobalt | :cobalt-classic-c:`#9FC6E7` | :cobalt-modern-c:`#4285F4` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '16' | Blueberry | :blueberry-classic-c:`#4986E7` | :blueberry-modern-c:`#3F51B5` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '17' | Lavender | :lavender-classic-c:`#9A9CFF` | :lavender-modern-c:`#7986CB` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '18' | Wisteria | :wisteria-classic-c:`#B99AFF` | :wisteria-modern-c:`#B39DDB` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '19' | Graphite | :graphite-classic-c:`#C2C2C2` | :graphite-modern-c:`#616161` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '20' | Birch | :birch-classic-c:`#CABDBF` | :birch-modern-c:`#A79B8E` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '21' | Radicchio | :radicchio-classic-c:`#CCA6AC` | :radicchio-modern-c:`#AD1457` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '22' | Cherry Blossom | :cherry-blossom-classic-c:`#F691B2` | :cherry-blossom-modern-c:`#D81B60` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '23' | Grape | :grape-classic-c:`#CD74E6` | :grape-modern-c:`#8E24AA` | + +----------+------------------+-------------------------------------+-------------------------------------+ + | '24' | Amethyst | :amethyst-classic-c:`#A47AE2` | :amethyst-modern-c:`#9E69AF` | + +----------+------------------+-------------------------------------+-------------------------------------+ + + + +Set calendar list entry color +----------------------------- + +Use color ID in :py:class:`~gcsa.calendar.CalendarListEntry`'s `color_id` field or hex values in `background_color` and +`foreground_color`: + +1. Get a calendar list entry + +.. code-block:: python + + calendar_list_entry = gc.get_calendar_list_entry('') + +2. Set a new color ID + +.. code-block:: python + + GRAPHITE_COLOR_ID = '19' + calendar_list_entry.color_id = GRAPHITE_COLOR_ID + +or set hex values of `background_color` and `foreground_color`: + +.. code-block:: python + + calendar_list_entry.background_color = "#626364" + calendar_list_entry.foreground_color = "#FFFFFF" + +3. Update calendar list entry: + +.. code-block:: python + + calendar_list_entry = gc.update_calendar_list_entry(calendar_list_entry) + + + +.. Add background color from the text to the table cell +.. raw:: html + + \ No newline at end of file diff --git a/google-calendar-simple-api/docs/source/conf.py b/google-calendar-simple-api/docs/source/conf.py new file mode 100644 index 0000000000000000000000000000000000000000..0fcc916bafb06a9fd8c8fb59cb7c6fca2fe6f796 --- /dev/null +++ b/google-calendar-simple-api/docs/source/conf.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/master/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys +import sphinx_rtd_theme + +sys.path.insert(0, os.path.abspath('../../')) + +# -- Project information ----------------------------------------------------- + +project = 'Google Calendar Simple API' +copyright = '2019, Yevhen Kuzmovych' +author = 'Yevhen Kuzmovych' + +# The full version, including alpha/beta/rc tags +release = '' + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.doctest', + 'sphinx.ext.intersphinx', + 'sphinx.ext.autosummary', + 'sphinx_rtd_theme' +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = 'en' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = None + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + + +# -- Options for HTMLHelp output --------------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = 'GoogleCalendarSimpleAPIdoc' + +# -- Options for LaTeX output ------------------------------------------------ + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'GoogleCalendarSimpleAPI.tex', 'Google Calendar Simple API Documentation', + 'Yevhen Kuzmovych', 'manual'), +] + +# -- Options for manual page output ------------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'googlecalendarsimpleapi', 'Google Calendar Simple API Documentation', + [author], 1) +] + +# -- Options for Texinfo output ---------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'GoogleCalendarSimpleAPI', 'Google Calendar Simple API Documentation', + author, 'GoogleCalendarSimpleAPI', 'One line description of project.', + 'Miscellaneous'), +] + +# -- Options for Epub output ------------------------------------------------- + +# Bibliographic Dublin Core info. +epub_title = project + +# The unique identifier of the text. This can be a ISBN number +# or the project homepage. +# +# epub_identifier = '' + +# A unique identification for the text. +# +# epub_uid = '' + +# A list of files that should not be packed into the epub file. +epub_exclude_files = ['search.html'] + +# -- Extension configuration ------------------------------------------------- +intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} + +autoclass_content = 'both' + +autodoc_member_order = 'bysource' +autodoc_typehints = "description" + +html_theme_options = { + 'analytics_id': 'G-PT0TPQSZ56', +} + +html_css_files = [ + 'css/custom.css', + 'css/colors.css', +] + +autodoc_preserve_defaults = True diff --git a/google-calendar-simple-api/docs/source/conference.rst b/google-calendar-simple-api/docs/source/conference.rst new file mode 100644 index 0000000000000000000000000000000000000000..3639d5a32ad8d38edea4a3d991b4d9c75813caa9 --- /dev/null +++ b/google-calendar-simple-api/docs/source/conference.rst @@ -0,0 +1,105 @@ +.. _conference: + +Conference +---------- + +To add conference (such as Hangouts or Google Meet) to an event you can use :py:class:`~gcsa.conference.ConferenceSolution` +(for existing conferences) or :py:class:`~gcsa.conference.ConferenceSolutionCreateRequest` (to create new conference) +and pass it as a ``conference_solution`` parameter: + + +Existing conference +~~~~~~~~~~~~~~~~~~~ + +To add existing conference you need to specify its ``solution_type`` (see :py:class:`~gcsa.conference.SolutionType` for +available values) and at least one :py:class:`~gcsa.conference.EntryPoint` in ``entry_points`` parameter. You can pass +single :py:class:`~gcsa.conference.EntryPoint`: + +.. code-block:: python + + + from gcsa.conference import ConferenceSolution, EntryPoint, SolutionType + + event = Event( + 'Meeting', + start=(22 / Nov / 2020)[15:00], + conference_solution=ConferenceSolution( + entry_points=EntryPoint( + EntryPoint.VIDEO, + uri='https://meet.google.com/aaa-bbbb-ccc' + ), + solution_type=SolutionType.HANGOUTS_MEET, + ) + ) + +or multiple entry points in a list: + +.. code-block:: python + + event = Event( + 'Event with conference', + start=(22 / Nov / 2020)[15:00], + conference_solution=ConferenceSolution( + entry_points=[ + EntryPoint( + EntryPoint.VIDEO, + uri='https://meet.google.com/aaa-bbbb-ccc' + ), + EntryPoint( + EntryPoint.PHONE, + uri='tel:+12345678900' + ) + ], + solution_type=SolutionType.HANGOUTS_MEET, + ) + ) + +See more parameters for :py:class:`~gcsa.conference.ConferenceSolution` and :py:class:`~gcsa.conference.EntryPoint`. + + +New conference +~~~~~~~~~~~~~~ +To generate new conference you need to specify its ``solution_type`` (see :py:class:`~gcsa.conference.SolutionType` for +available values). + +.. code-block:: python + + + from gcsa.conference import ConferenceSolutionCreateRequest, SolutionType + + event = Event( + 'Meeting', + start=(22 / Nov / 2020)[15:00], + conference_solution=ConferenceSolutionCreateRequest( + solution_type=SolutionType.HANGOUTS_MEET, + ) + ) + +See more parameters for :py:class:`~gcsa.conference.ConferenceSolutionCreateRequest`. + +.. note:: Create requests are asynchronous. Check ``status`` field of event's ``conference_solution`` to find it's + status. If the status is ``"success"``, ``conference_solution`` will contain a + :py:class:`~gcsa.conference.ConferenceSolution` object and you'll be able to access its fields (like + ``entry_points``). Otherwise (if ``status`` is ``"pending"`` or ``"failure"``), ``conference_solution`` will + contain a :py:class:`~gcsa.conference.ConferenceSolutionCreateRequest` object. + + +.. code-block:: python + + event = calendar.add_event( + Event( + 'Meeting', + start=(22 / Nov / 2020)[15:00], + conference_solution=ConferenceSolutionCreateRequest( + solution_type=SolutionType.HANGOUTS_MEET, + ) + ) + ) + + if event.conference_solution.status == 'success': + print(event.conference_solution.solution_id) + print(event.conference_solution.entry_points) + elif event.conference_solution.status == 'pending': + print('Conference request has not been processed yet.') + elif event.conference_solution.status == 'failure': + print('Conference request has failed.') diff --git a/google-calendar-simple-api/docs/source/events.rst b/google-calendar-simple-api/docs/source/events.rst new file mode 100644 index 0000000000000000000000000000000000000000..a22d7cb555689e5d1597cd399463c787a4bcc560 --- /dev/null +++ b/google-calendar-simple-api/docs/source/events.rst @@ -0,0 +1,194 @@ +.. _events: + +Events +====== + +Event in `gcsa` is represented by the class :py:class:`~gcsa.event.Event`. It stores all the needed information about +the event including its summary, starting and ending dates/times, attachments, reminders, recurrence rules, etc. + +`gcsa` allows you to create a new events, retrieve, update, move and delete existing events. + +To do so, create a :py:class:`~gcsa.google_calendar.GoogleCalendar` instance (see :ref:`getting_started` to get your +credentials): + +.. code-block:: python + + from gcsa.google_calendar import GoogleCalendar + + gc = GoogleCalendar() + + +List events +~~~~~~~~~~~ + +This code will print out events for one year starting today: + +.. code-block:: python + + for event in gc: + print(event) + +.. note:: + In the following examples, :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_events` and + :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_instances` return generators_. You can iterate over them directly: + + .. code-block:: + + for event in gc.get_events(): + print(event) + + but to get the list of events use: + + .. code-block:: + + events = list(gc.get_events()) + +Specify range of listed events in two ways: + +.. code-block:: python + + events = gc.get_events(time_min, time_max, order_by='updated') + +or + +.. code-block:: python + + events = gc[time_min:time_max:'updated'] + +``time_min`` and ``time_max`` can be ``date`` or ``datetime`` objects. ``order_by`` can be `'startTime'` +or `'updated'`. If not specified, unspecified stable order is used. + + +Use ``query`` parameter for free text search through all event fields (except for extended properties): + +.. code-block:: python + + events = gc.get_events(query='Meeting') + +or + +.. code-block:: python + + events = gc.get_events(query='John') # Name of attendee + + +Use ``single_events`` parameter to expand recurring events into instances and only return single one-off events and +instances of recurring events, but not the underlying recurring events themselves. + +.. code-block:: python + + events = gc.get_events(single_events=True) + + + +List recurring event instances +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + events = gc.get_instances('') + +or + +.. code-block:: python + + events = gc.get_instances(recurring_event) + +where ``recurring_event`` is :py:class:`~gcsa.event.Event` object with set ``event_id``. You'd probably get it from +the ``get_events`` method. + +Get event by id +~~~~~~~~~~~~~~~ + +.. code-block:: python + + event = gc.get_event('') + +Create event +~~~~~~~~~~~~ + +.. code-block:: python + + from beautiful_date import Apr, hours + from gcsa.event import Event + + start = (22/Apr/2019)[12:00] + end = start + 2 * hours + event = Event('Meeting', + start=start, + end=end) + +or to create an **all-day** event, use a `date` object: + +.. code-block:: python + + from beautiful_date import Aug, days + from gcsa.event import Event + + start = 1/Aug/2021 + end = start + 7 * days + event = Event('Vacation', + start=start, + end=end) + + +For ``date``/``datetime`` objects you can use Pythons datetime_ module or as in the +example beautiful_date_ library (*because it's beautiful... just like you*). + +Now **add** your event to the calendar: + +.. code-block:: python + + event = gc.add_event(event) + +See dedicated pages on how to add :ref:`attendees`, :ref:`attachments`, :ref:`conference`, :ref:`reminders`, and +:ref:`recurrence` to an event. + + +Update event +~~~~~~~~~~~~ + +.. code-block:: python + + event.location = 'Prague' + event = gc.update_event(event) + + +Import event +~~~~~~~~~~~~ + +.. code-block:: python + + event = gc.import_event(event) + +This operation is used to add a private copy of an existing event to a calendar. + + +Move event to another calendar +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + event = gc.move_event(event, destination_calendar_id='primary') + + +Delete event +~~~~~~~~~~~~ + +.. code-block:: python + + gc.delete_event(event) + + +Event has to have ``event_id`` to be updated, moved, or deleted. Events that you get from +:py:meth:`~gcsa.google_calendar.GoogleCalendar.get_events` method already have their ids. +You can also delete the event by providing its id. + +.. code-block:: python + + gc.delete_event('') + + +.. _datetime: https://docs.python.org/3/library/datetime.html +.. _beautiful_date: https://github.com/kuzmoyev/beautiful-date +.. _generators: https://wiki.python.org/moin/Generators diff --git a/google-calendar-simple-api/docs/source/free_busy.rst b/google-calendar-simple-api/docs/source/free_busy.rst new file mode 100644 index 0000000000000000000000000000000000000000..2dfe1433b2c18b0788a4fe2a03d7900737f966d8 --- /dev/null +++ b/google-calendar-simple-api/docs/source/free_busy.rst @@ -0,0 +1,94 @@ +.. _free_busy: + +Free busy +========= + +With `gcsa` you can retrieve the free/busy information of the calendars and groups. + +To do so, create a :py:class:`~gcsa.google_calendar.GoogleCalendar` instance (see :ref:`getting_started` to get your +credentials): + + +.. code-block:: python + + from gcsa.google_calendar import GoogleCalendar + + gc = GoogleCalendar() + + +Then to retrieve a free/busy information of the calendar for the following two weeks starting now use +:py:meth:`~gcsa.google_calendar.GoogleCalendar.get_free_busy`: + +.. code-block:: python + + free_busy = gc.get_free_busy() + +this will return a :py:class:`~gcsa.free_busy.FreeBusy` object. If only one calendar has been requested (like in +the example above, only "primary" calendar's information has been requested), you can iterate over +:py:class:`~gcsa.free_busy.FreeBusy` object directly: + + +.. code-block:: python + + for start, end in free_busy: + print(f'Busy from {start} to {end}') + +To request group(s) or different calendar(s) (other than one specified as default during `GoogleCalendar` creation), +use `resource_ids` argument of :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_free_busy`: + + +.. code-block:: python + + free_busy = gc.get_free_busy('secondary_calendar_id@gmail.com') + + for start, end in free_busy: + print(f'Busy from {start} to {end}') + +or + +.. code-block:: python + + free_busy = gc.get_free_busy( + [ + 'primary', + 'secondary_calendar_id@gmail.com', + 'group_id' + ] + ) + + print('Primary calendar:') + for start, end in free_busy.calendars['primary']: + print(f'Busy from {start} to {end}') + + print('Secondary calendar:') + for start, end in free_busy.calendars['secondary_calendar_id@gmail.com']: + print(f'Busy from {start} to {end}') + + print('Group info:') + for calendar in free_busy.groups['group_id']: + print(f'{calendar}:') + for start, end in free_busy.calendars[calendar]: + print(f'Busy from {start} to {end}') + +Some calendars or groups in the request might cause errors. By default `gcsa` will +raise :py:class:`~gcsa.free_busy.FreeBusyQueryError` in case of any errors. But you can ignore them with `ignore_errors` +argument: + +.. code-block:: python + + free_busy = gc.get_free_busy( + resource_ids=[ + 'primary', + 'secondary_calendar_id@gmail.com', + 'group_id' + ], + ignore_errors=True + ) + +In that case, all the errors can be found in :py:class:`~gcsa.free_busy.FreeBusy`'s `groups_errors` and +`calendars_errors` fields: + +.. code-block:: python + + print(free_busy.groups_errors) + print(free_busy.calendars_errors) diff --git a/google-calendar-simple-api/docs/source/getting_started.rst b/google-calendar-simple-api/docs/source/getting_started.rst new file mode 100644 index 0000000000000000000000000000000000000000..f080d70d10ffb83b78268d50c7f596a0610e68d7 --- /dev/null +++ b/google-calendar-simple-api/docs/source/getting_started.rst @@ -0,0 +1,101 @@ +.. _getting_started: + +Getting started +=============== + + +Installation +------------ + +To install ``gcsa`` run the following command: + +.. code-block:: bash + + pip install gcsa + + +from sources: + +.. code-block:: bash + + git clone git@github.com:kuzmoyev/google-calendar-simple-api.git + cd google-calendar-simple-api + python setup.py install + +Credentials +----------- + +Now you need to get your API credentials: + + + 1. `Create a new Google Cloud Platform (GCP) project`_ + + .. note:: You will need to enable the "Google Calendar API" for your project. + + 2. `Configure the OAuth consent screen`_ + 3. `Create a OAuth client ID credential`_ and download the ``credentials.json`` (``client_secret_*.json``) file + 4. Put downloaded ``credentials.json`` (``client_secret_*.json``) file into ``~/.credentials/`` directory + + +.. _`Create a new Google Cloud Platform (GCP) project`: https://developers.google.com/workspace/guides/create-project +.. _`Configure the OAuth consent screen`: https://developers.google.com/workspace/guides/configure-oauth-consent +.. _`Create a OAuth client ID credential`: https://developers.google.com/workspace/guides/create-credentials#oauth-client-id + + +See more options in :ref:`authentication`. + + .. note:: You can put ``credentials.json`` (``client_secret_*.json``) file anywhere you want and specify + the path to it in your code afterwords. But remember not to share it (e.g. add it + to ``.gitignore``) as it is your private credentials. + + .. note:: + | On the first run, your application will prompt you to the default browser + to get permissions from you to use your calendar. This will create + ``token.pickle`` file in the same folder (unless specified otherwise) as your + ``credentials.json`` (``client_secret_*.json``). So don't forget to also add it to ``.gitignore`` if + it is in a GIT repository. + | If you don't want to save it in ``.pickle`` file, you can use ``save_token=False`` + when initializing the ``GoogleCalendar``. + +Quick example +------------- + +The following code will create a recurrent event in your calendar starting on January 1 and +repeating everyday at 9:00am except weekends and two holidays (April 19, April 22). + +Then it will list all events for one year starting today. + +For ``date``/``datetime`` objects you can use Pythons datetime_ module or as in the +example beautiful_date_ library (*because it's beautiful... just like you*). + +.. code-block:: python + + from gcsa.event import Event + from gcsa.google_calendar import GoogleCalendar + from gcsa.recurrence import Recurrence, DAILY, SU, SA + + from beautiful_date import Jan, Apr + + + calendar = GoogleCalendar('your_email@gmail.com') + event = Event( + 'Breakfast', + start=(1 / Jan / 2019)[9:00], + recurrence=[ + Recurrence.rule(freq=DAILY), + Recurrence.exclude_rule(by_week_day=[SU, SA]), + Recurrence.exclude_times([ + (19 / Apr / 2019)[9:00], + (22 / Apr / 2019)[9:00] + ]) + ], + minutes_before_email_reminder=50 + ) + + calendar.add_event(event) + + for event in calendar: + print(event) + +.. _datetime: https://docs.python.org/3/library/datetime.html +.. _beautiful_date: https://github.com/kuzmoyev/beautiful-date diff --git a/google-calendar-simple-api/docs/source/index.rst b/google-calendar-simple-api/docs/source/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..01843388758183b4f622d7d8d722b356d7d6d6d1 --- /dev/null +++ b/google-calendar-simple-api/docs/source/index.rst @@ -0,0 +1,92 @@ +Google Calendar Simple API documentation! +========================================= + +`Google Calendar Simple API` or `gcsa` is a library that simplifies event and calendar management in Google Calendars. +It is a Pythonic object oriented adapter for the `official API`_. + +Example usage +------------- + +List events +~~~~~~~~~~~ + +.. code-block:: python + + from gcsa.google_calendar import GoogleCalendar + + calendar = GoogleCalendar('your_email@gmail.com') + for event in calendar: + print(event) + + +Create event +~~~~~~~~~~~~ + +.. code-block:: python + + from gcsa.event import Event + + event = Event( + 'The Glass Menagerie', + start=datetime(2020, 7, 10, 19, 0), + location='ZΓ‘hΕ™ebskΓ‘ 468/21' + minutes_before_popup_reminder=15 + ) + calendar.add_event(event) + + +Create recurring event +~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from gcsa.recurrence import Recurrence, DAILY + + event = Event( + 'Breakfast', + start=date(2020, 7, 16), + recurrence=Recurrence.rule(freq=DAILY) + ) + calendar.add_event(event) + + +Contents +-------- + +.. toctree:: + :maxdepth: 2 + + getting_started + authentication + events + calendars + colors + attendees + attachments + conference + reminders + recurrence + acl + free_busy + settings + serializers + why_gcsa + change_log + code/code + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + + +References +========== + +Template for `setup.py` was taken from `kennethreitz/setup.py`_. + + +.. _kennethreitz/setup.py: https://github.com/kennethreitz/setup.py +.. _`official API`: https://developers.google.com/calendar \ No newline at end of file diff --git a/google-calendar-simple-api/docs/source/recurrence.rst b/google-calendar-simple-api/docs/source/recurrence.rst new file mode 100644 index 0000000000000000000000000000000000000000..1dadf0746d2cbd4c51d59472e5a246d693f10987 --- /dev/null +++ b/google-calendar-simple-api/docs/source/recurrence.rst @@ -0,0 +1,300 @@ +.. _recurrence: + +Recurrence +========== + +With ``gcsa`` you can create recurrent events. Use :py:mod:`~gcsa.recurrence` module. + +There are 8 methods that you can use to define recurrence rules: + + * :py:meth:`~gcsa.recurrence.Recurrence.rule` - rule that defines recurrence + * :py:meth:`~gcsa.recurrence.Recurrence.exclude_rule` - rule that defines excluded dates/datetimes + * :py:meth:`~gcsa.recurrence.Recurrence.dates` - date or list of dates to include + * :py:meth:`~gcsa.recurrence.Recurrence.exclude_dates` - date or list of dates to exclude + * :py:meth:`~gcsa.recurrence.Recurrence.times` - datetime or list of datetimes to include + * :py:meth:`~gcsa.recurrence.Recurrence.exclude_times` - datetime or list of datetimes to exclude + * :py:meth:`~gcsa.recurrence.Recurrence.periods` - period or list of periods to include + * :py:meth:`~gcsa.recurrence.Recurrence.exclude_periods` - period or list of periods to exclude + +.. note:: Methods ``{method}`` have the same format and parameters as theirΒ ``exclude_{method}`` + counterparts. So all examples for ``{method}`` also apply to ``exclude_{method}``. + +These methods return strings in ``RRULE`` format that you can pass as a ``recurrence`` parameter +to the :py:class:`~gcsa.event.Event` objects. You can pass one string or list of strings. +For example: + +.. code-block:: python + + Event('Breakfast', + (1/Jan/2020)[9:00], + (1/Jan/2020)[10:00], + recurrence=Recurrence.rule(freq=DAILY)) + +or + +.. code-block:: python + + Event('Breakfast', + (1/Jan/2019)[9:00], + (1/Jan/2020)[9:00], + recurrence=[ + Recurrence.rule(freq=DAILY), + Recurrence.exclude_rule(by_week_day=[SU, SA]) + ]) + + + +Examples +-------- + +You will need to import :py:class:`~gcsa.recurrence.Recurrence` class and optionally other +auxiliary classes and objects: + +.. code-block:: python + + from gcsa.recurrence import Recurrence + + # days of the week + from gcsa.recurrence import SU, MO, TU, WE, TH, FR, SA + + # possible repetition frequencies + from gcsa.recurrence import SECONDLY, MINUTELY, HOURLY, \ + DAILY, WEEKLY, MONTHLY, YEARLY + +`Earth Hour`_, which occurs on the last Saturday of March every year: + +.. code-block:: python + + from datetime import datetime + + r = Recurrence.rule(freq=MONTHLY, interval=12, by_week_day=SA(-1)) + start = datetime(year=2024, month=3, day=23, hour=20, minute=30) + event = Event("Earth hour", start=start, recurrence=r) + + event = gc.add_event(event) + + +Following examples were taken from the `Internet Calendaring and Scheduling Core Object Specification (iCalendar)`_ +and adapted to ``gcsa``. + + +`Daily for 10 occurrences`: + +.. code-block:: python + + Recurrence.rule(freq=DAILY, count=10) + +or as ``DAILY`` is a default frequency: + +.. code-block:: python + + Recurrence.rule(count=10) + + +`Every other day`: + +.. code-block:: python + + Recurrence.rule(freq=DAILY, interval=2) + + +`Every 10 days, 5 occurrences`: + +.. code-block:: python + + Recurrence.rule(count=5, interval=10) + + +`Every day in January`: + +.. code-block:: python + + Recurrence.rule(freq=YEARLY, + by_month=1, + by_week_day=[SU,MO,TU,WE,TH,FR,SA]) + +or + +.. code-block:: python + + Recurrence.rule(freq=DAILY, by_month=1) + + +`Weekly for 10 occurrences`: + +.. code-block:: python + + Recurrence.rule(freq=WEEKLY, count=10) + +`Weekly on Tuesday and Thursday`: + +.. code-block:: python + + Recurrence.rule(freq=WEEKLY, + by_week_day=[TU, TH]) + +`Every other week on Monday, Wednesday, and Friday`: + +.. code-block:: python + + Recurrence.rule(freq=WEEKLY, + interval=2, + by_week_day=[MO, WE, FR]) + + +`Every other week on Tuesday and Thursday, for 8 occurrences`: + +.. code-block:: python + + Recurrence.rule(freq=WEEKLY, + interval=2, + count=8, + by_week_day=[TU, TH]) + +`Monthly on the first Friday for 10 occurrences`: + +.. code-block:: python + + Recurrence.rule(freq=MONTHLY, + count=10, + by_week_day=FR(1)) + +`Every other month on the first and last Sunday of the month for 10 occurrences`: + +.. code-block:: python + + Recurrence.rule(freq=MONTHLY, + interval=2, + count=10, + by_week_day=[SU(1), SU(-1)]) + + +`Monthly on the second-to-last Monday of the month for 6 months`: + +.. code-block:: python + + Recurrence.rule(freq=MONTHLY, + count=6, + by_week_day=MO(-2)) + + +`Monthly on the third-to-the-last day of the month`: + +.. code-block:: python + + Recurrence.rule(freq=MONTHLY, + by_month_day=-3) + + +`Monthly on the 2nd and 15th of the month for 10 occurrences`: + +.. code-block:: python + + Recurrence.rule(freq=MONTHLY, + count=10, + by_month_day=[2, 15]) + + +`Monthly on the first and last day of the month for 10 occurrences`: + +.. code-block:: python + + Recurrence.rule(freq=MONTHLY, + count=10, + by_month_day=[1, -1]) + +`Every 18 months on the 10th thru 15th of the month for 10 occurrences`: + +.. code-block:: python + + Recurrence.rule(freq=MONTHLY, + interval=18, + count=10, + by_month_day=list(range(10, 16))) + + +`Every Tuesday, every other month`: + +.. code-block:: python + + Recurrence.rule(freq=MONTHLY, + interval=2, + by_week_day=TU) + + +`Yearly in June and July for 10 occurrences`: + +.. code-block:: python + + Recurrence.rule(freq=YEARLY, + count=10, + by_month=[6, 7]) + + +`Every third year on the 1st, 100th, and 200th day for 10 occurrences`: + +.. code-block:: python + + Recurrence.rule(freq=YEARLY, + interval=3, + count=10, + by_year_day=[1, 100, 200]) + + +`Every 20th Monday of the year`: + +.. code-block:: python + + Recurrence.rule(freq=YEARLY, + by_week_day=MO(20)) + + +`Monday of week number 20 (where the default start of the week is Monday)`: + +.. code-block:: python + + Recurrence.rule(freq=YEARLY, + by_week=20, + week_start=MO) + + +`Every Thursday in March`: + +.. code-block:: python + + Recurrence.rule(freq=YEARLY, + by_month=3, + by_week_day=TH) + + +`The third instance into the month of one of Tuesday, Wednesday, or +Thursday, for the next 3 months`: + +.. code-block:: python + + Recurrence.rule(freq=MONTHLY, + count=3, + by_week_day=[TU, WE, TH], + by_set_pos=3) + + +`The second-to-last weekday of the month`: + +.. code-block:: python + + Recurrence.rule(freq=MONTHLY, + by_week_day=[MO, TU, WE, TH, FR], + by_set_pos=-2) + + +`Every 20 minutes from 9:00 AM to 4:40 PM every day`: + +.. code-block:: python + + Recurrence.rule(freq=DAILY, + by_hour=list(range(9, 17)), + by_minute=[0, 20, 40]) + + +.. _`Internet Calendaring and Scheduling Core Object Specification (iCalendar)`: https://tools.ietf.org/html/rfc5545#section-3.8.5 +.. _`Earth Hour`: https://www.earthhour.org/ diff --git a/google-calendar-simple-api/docs/source/reminders.rst b/google-calendar-simple-api/docs/source/reminders.rst new file mode 100644 index 0000000000000000000000000000000000000000..9f13b32d65dd218b64b778d363db1f6de39655b8 --- /dev/null +++ b/google-calendar-simple-api/docs/source/reminders.rst @@ -0,0 +1,115 @@ +.. _reminders: + +Reminders +--------- + +To add reminder(s) to an event you can create :py:class:`~gcsa.reminders.EmailReminder` or +:py:class:`~gcsa.reminders.PopupReminder` and pass them as a ``reminders`` parameter (single reminder +or list of reminders): + + +.. code-block:: python + + + from gcsa.reminders import EmailReminder, PopupReminder + + event = Event('Meeting', + start=(22/Apr/2019)[12:00], + reminders=EmailReminder(minutes_before_start=30)) + +or + +.. code-block:: python + + event = Event('Meeting', + start=(22/Apr/2019)[12:00], + reminders=[ + EmailReminder(minutes_before_start=30), + EmailReminder(minutes_before_start=60), + PopupReminder(minutes_before_start=15) + ]) + + +You can also simply add reminders by specifying ``minutes_before_popup_reminder`` and/or +``minutes_before_email_reminder`` parameter of the :py:class:`~gcsa.event.Event` object: + +.. code-block:: python + + event = Event('Meeting', + start=(22/Apr/2019)[12:00], + minutes_before_popup_reminder=15, + minutes_before_email_reminder=30) + + +If you want to add a reminder to an existing event use :py:meth:`~gcsa.event.Event.add_email_reminder` +and/or :py:meth:`~gcsa.event.Event.add_popup_reminder` methods: + + +.. code-block:: python + + event.add_popup_reminder(minutes_before_start=30) + event.add_email_reminder(minutes_before_start=50) + +Update event using :py:meth:`~gcsa.google_calendar.GoogleCalendar.update_event` method to save the changes. + +To use default reminders of the calendar, set ``default_reminders`` parameter of the :py:class:`~gcsa.event.Event` +to ``True``. + +.. note:: You can add up to 5 reminders to one event. + +Specific time reminders +~~~~~~~~~~~~~~~~~~~~~~~ + +You can also set specific time for a reminder. + +.. code-block:: python + + from datetime import time + + event = Event( + 'Meeting', + start=(22/Apr/2019)[12:00], + reminders=[ + # Day before the event at 13:30 + EmailReminder(days_before=1, at=time(13, 30)), + # 2 days before the event at 19:15 + PopupReminder(days_before=2, at=time(19, 15)) + ] + ) + + event.add_popup_reminder(days_before=3, at=time(8, 30)) + event.add_email_reminder(days_before=4, at=time(9, 0)) + + +.. note:: Google calendar API only works with ``minutes_before_start``. + The GCSA's interface that uses ``days_before`` and ``at`` arguments is only a convenient way of setting specific time. + GCSA will convert ``days_before`` and ``at`` to ``minutes_before_start`` during API requests. + So after you add or update the event, it will have reminders with only ``minutes_before_start`` set even if they + were initially created with ``days_before`` and ``at``. + + .. code-block:: python + + from datetime import time + + event = Event( + 'Meeting', + start=(22/Apr/2019)[12:00], + reminders=[ + # Day before the event at 12:00 + EmailReminder(days_before=1, at=time(12, 00)) + ] + ) + + event.reminders[0].minutes_before_start is None + event.reminders[0].days_before == 1 + event.reminders[0].at == time(12, 00) + + event = gc.add_event(event) + + event.reminders[0].minutes_before_start == 24 * 60 # exactly one day before + event.reminders[0].days_before is None + event.reminders[0].at is None + + GCSA does not convert ``minutes_before_start`` to ``days_before`` and ``at`` (even for the whole-day events) + for backwards compatibility reasons. + diff --git a/google-calendar-simple-api/docs/source/serializers.rst b/google-calendar-simple-api/docs/source/serializers.rst new file mode 100644 index 0000000000000000000000000000000000000000..069783403c15dc8bc8196b46e9e598c0ee7123bd --- /dev/null +++ b/google-calendar-simple-api/docs/source/serializers.rst @@ -0,0 +1,805 @@ +Serializers +=========== + +The library implements the JSON serializers for all available Google Calendar objects. JSON format is as specified in +the `official API documentation`_. In general, you won't need to use them, ``gcsa`` serializes everything as needed +under the hood. It is documented just so you know they exist and can be used if necessary. + +.. note:: + Note that serializers' ``to_json`` methods ignore read-only fields of the objects. + Read-only fields of the objects are ones that are passed to the parameters of their ``__init__`` with + underscores, e.g. ``Event(_updated=25/Nov/2020)``. + +Events serializer +~~~~~~~~~~~~~~~~~ + +To json +------- + +.. code-block:: python + + from gcsa.event import Event + from gcsa.serializers.event_serializer import EventSerializer + + + event = Event( + 'Meeting', + start=(22 / Nov / 2020)[18:00] + ) + + EventSerializer.to_json(event) + +.. code-block:: javascript + + { + 'summary': 'Meeting', + 'start': { + 'dateTime': '2020-11-22T18:00:00+01:00', + 'timeZone': 'Europe/Prague' + }, + 'end': { + 'dateTime': '2020-11-22T19:00:00+01:00', + 'timeZone': 'Europe/Prague' + }, + 'attachments': [], + 'attendees': [], + 'recurrence': [], + 'reminders': {'useDefault': False}, + 'visibility': 'default' + } + + +To object +--------- + +.. code-block:: python + + event_json = { + 'start': { + 'dateTime': '2020-11-22T18:00:00+01:00', + 'timeZone': 'Europe/Prague' + }, + 'end': { + 'dateTime': '2020-11-22T19:00:00+01:00', + 'timeZone': 'Europe/Prague' + }, + 'attachments': [], + 'attendees': [], + 'recurrence': [], + 'reminders': {'useDefault': False}, + 'summary': 'Meeting', + 'visibility': 'default' + } + + EventSerializer.to_object(event_json) + +.. code-block:: python + + + +Attachments serializer +~~~~~~~~~~~~~~~~~~~~~~ + +To json +------- + +.. code-block:: python + + from gcsa.attachment import Attachment + from gcsa.serializers.attachment_serializer import AttachmentSerializer + + attachment = Attachment( + file_url='https://bit.ly/3lZo0Cc', + title='My file', + mime_type='application/vnd.google-apps.document' + ) + + AttachmentSerializer.to_json(attachment) + +.. code-block:: javascript + + { + 'title': 'My file', + 'fileUrl': 'https://bit.ly/3lZo0Cc', + 'mimeType': 'application/vnd.google-apps.document' + } + + +To object +--------- + +.. code-block:: python + + attachment_json = { + 'fileUrl': 'https://bit.ly/3lZo0Cc', + 'mimeType': 'application/vnd.google-apps.document', + 'title': 'My file' + } + + AttachmentSerializer.to_object(attachment_json) + +.. code-block:: python + + + + + +Person serializer +~~~~~~~~~~~~~~~~~ + +To json +------- + +.. code-block:: python + + from gcsa.person import Person + from gcsa.serializers.person_serializer import PersonSerializer + + person = Person( + 'john@gmail.com', + display_name='BFF', + ) + + PersonSerializer.to_json(person) + +.. code-block:: javascript + + { + 'email': 'john@gmail.com' + 'displayName': 'BFF', + } + + +To object +--------- + + +.. code-block:: python + + person_json = { + 'email': 'john@gmail.com', + 'displayName': 'BFF', + 'id': '123123', + 'self': True + } + + PersonSerializer.to_object(person_json) + +.. code-block:: python + + + + +Attendees serializer +~~~~~~~~~~~~~~~~~~~~ + +To json +------- + +.. code-block:: python + + from gcsa.attendee import Attendee + from gcsa.serializers.attendee_serializer import AttendeeSerializer + + attendee = Attendee( + 'john@gmail.com', + display_name='BFF', + additional_guests=2 + ) + + AttendeeSerializer.to_json(attendee) + +.. code-block:: javascript + + { + 'email': 'john@gmail.com' + 'displayName': 'BFF', + 'additionalGuests': 2, + } + + +To object +--------- + +.. code-block:: python + + attendee_json = { + 'email': 'john@gmail.com', + 'displayName': 'BFF', + 'additionalGuests': 2, + 'responseStatus': 'needsAction' + } + + AttendeeSerializer.to_object(attendee_json) + +.. code-block:: python + + + + +Conference serializer +~~~~~~~~~~~~~~~~~~~~~ + +EntryPoint +---------- + +To json +******* + + +.. code-block:: python + + from gcsa.conference import EntryPoint + from gcsa.serializers.conference_serializer import EntryPointSerializer + + entry_point = EntryPoint( + EntryPoint.VIDEO, + uri='https://meet.google.com/aaa-bbbb-ccc' + ) + + EntryPointSerializer.to_json(entry_point) + +.. code-block:: javascript + + { + 'entryPointType': 'video', + 'uri': 'https://meet.google.com/aaa-bbbb-ccc' + } + + +To object +********* + +.. code-block:: python + + entry_point_json = { + 'entryPointType': 'video', + 'uri': 'https://meet.google.com/aaa-bbbb-ccc' + } + + EntryPointSerializer.to_object(entry_point_json) + +.. code-block:: python + + + + +ConferenceSolution +------------------ + +To json +******* + + +.. code-block:: python + + from gcsa.conference import ConferenceSolution, EntryPoint, SolutionType + from gcsa.serializers.conference_serializer import ConferenceSolutionSerializer + + conference_solution = ConferenceSolution( + entry_points=EntryPoint( + EntryPoint.VIDEO, + uri='https://meet.google.com/aaa-bbbb-ccc' + ), + solution_type=SolutionType.HANGOUTS_MEET, + ) + + ConferenceSolutionSerializer.to_json(conference_solution) + +.. code-block:: javascript + + { + 'conferenceSolution': { + 'key': { + 'type': 'hangoutsMeet' + } + }, + 'entryPoints': [ + { + 'entryPointType': 'video', + 'uri': 'https://meet.google.com/aaa-bbbb-ccc' + } + ] + } + + +To object +********* + +.. code-block:: python + + conference_solution_json = { + 'conferenceSolution': { + 'key': { + 'type': 'hangoutsMeet' + } + }, + 'entryPoints': [ + { + 'entryPointType': 'video', + 'uri': 'https://meet.google.com/aaa-bbbb-ccc' + } + ] + } + + ConferenceSolutionSerializer.to_object(conference_solution_json) + +.. code-block:: python + + ]> + + +ConferenceSolutionCreateRequest +------------------------------- + +To json +******* + + +.. code-block:: python + + from gcsa.conference import ConferenceSolutionCreateRequest, SolutionType + from gcsa.serializers.conference_serializer import ConferenceSolutionCreateRequestSerializer + + conference_solution_create_request = ConferenceSolutionCreateRequest( + solution_type=SolutionType.HANGOUTS_MEET, + ) + + ConferenceSolutionCreateRequestSerializer.to_json(conference_solution_create_request) + +.. code-block:: javascript + + { + 'createRequest': { + 'conferenceSolutionKey': { + 'type': 'hangoutsMeet' + }, + 'requestId': '30b8e7c4d595445aa73c3feccf4b4f06' + } + } + + +To object +********* + +.. code-block:: python + + conference_solution_create_request_json = { + 'createRequest': { + 'conferenceSolutionKey': { + 'type': 'hangoutsMeet' + }, + 'requestId': '30b8e7c4d595445aa73c3feccf4b4f06', + 'status': { + 'statusCode': 'pending' + } + } + } + + ConferenceSolutionCreateRequestSerializer.to_object(conference_solution_create_request_json) + +.. code-block:: python + + + + +Reminders serializer +~~~~~~~~~~~~~~~~~~~~ + +To json +------- + +.. code-block:: python + + from gcsa.reminders import EmailReminder, PopupReminder + from gcsa.serializers.reminder_serializer import ReminderSerializer + + reminder = EmailReminder(minutes_before_start=30) + + ReminderSerializer.to_json(reminder) + +.. code-block:: javascript + + { + 'method': 'email', + 'minutes': 30 + } + +.. code-block:: python + + reminder = PopupReminder(minutes_before_start=30) + + ReminderSerializer.to_json(reminder) + +.. code-block:: javascript + + { + 'method': 'popup', + 'minutes': 30 + } + + +To object +--------- + +.. code-block:: python + + reminder_json = { + 'method': 'email', + 'minutes': 30 + } + + ReminderSerializer.to_object(reminder_json) + +.. code-block:: python + + + +.. code-block:: python + + reminder_json = { + 'method': 'popup', + 'minutes': 30 + } + + ReminderSerializer.to_object(reminder_json) + +.. code-block:: python + + + + + +Calendars serializer +~~~~~~~~~~~~~~~~~~~~ + +To json +------- + +.. code-block:: python + + from gcsa.calendar import Calendar, AccessRoles + from gcsa.serializers.calendar_serializer import CalendarSerializer + + calendar = Calendar( + summary='Primary', + calendar_id='primary', + description='Description', + location='Location', + timezone='Timezone', + allowed_conference_solution_types=[ + AccessRoles.FREE_BUSY_READER, + AccessRoles.READER, + AccessRoles.WRITER, + AccessRoles.OWNER, + ] + ) + + CalendarSerializer.to_json(calendar) + +.. code-block:: javascript + + { + 'id': 'primary', + 'summary': 'Primary', + 'description': 'Description', + 'location': 'Location', + 'timeZone': 'Timezone', + 'conferenceProperties': { + 'allowedConferenceSolutionTypes': [ + 'freeBusyReader', + 'reader', + 'writer', + 'owner' + ] + } + } + + +To object +--------- + +.. code-block:: python + + calendar_json = { + 'id': 'primary', + 'summary': 'Primary', + 'description': 'Description', + 'location': 'Location', + 'timeZone': 'Timezone', + 'conferenceProperties': { + 'allowedConferenceSolutionTypes': [ + 'freeBusyReader', + 'reader', + 'writer', + 'owner' + ] + } + } + CalendarSerializer.to_object(calendar_json) + +.. code-block:: python + + + + + +CalendarListEntry serializer +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To json +------- + +.. code-block:: python + + from gcsa.calendar import CalendarListEntry, NotificationType + from gcsa.reminders import EmailReminder + from gcsa.serializers.calendar_serializer import CalendarListEntrySerializer + + calendar_list_entry = CalendarListEntry( + calendar_id='', + summary_override='Holidays in Czechia 2022', + color_id='2', + background_color='#123456', + foreground_color='#234567', + hidden=True, + selected=False, + default_reminders=[EmailReminder(minutes_before_start=15)], + notification_types=[ + NotificationType.EVENT_CREATION, + NotificationType.EVENT_CHANGE + ] + ) + + CalendarListEntrySerializer.to_json(calendar_list_entry) + +.. code-block:: javascript + + { + 'id': '', + 'summaryOverride': 'Holidays in Czechia 2022', + 'colorId': '2', + 'backgroundColor': '#123456', + 'foregroundColor': '#234567', + 'hidden': True, + 'selected': False, + 'defaultReminders': [ + {'method': 'email', 'minutes': 15} + ], + 'notificationSettings': { + 'notifications': [ + {'type': 'eventCreation', 'method': 'email'}, + {'type': 'eventChange', 'method': 'email'} + ] + } + } + + +To object +--------- + +.. code-block:: python + + calendar_list_entry_json = { + 'id': '', + 'summary': 'StΓ‘tnΓ­ svΓ‘tky v ČR', + 'summaryOverride': 'Holidays in Czechia 2022', + 'colorId': '2', + 'backgroundColor': '#123456', + 'foregroundColor': '#234567', + 'hidden': True, + 'selected': False, + 'defaultReminders': [ + {'method': 'email', 'minutes': 15} + ], + 'notificationSettings': { + 'notifications': [ + {'type': 'eventCreation', 'method': 'email'}, + {'type': 'eventChange', 'method': 'email'} + ] + } + } + + CalendarListEntrySerializer.to_object(calendar_list_entry_json) + + +.. code-block:: python + + + + + +Access control rule serializer +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To json +------- + +.. code-block:: python + + from gcsa.acl import AccessControlRule, ACLRole, ACLScopeType + from gcsa.serializers.acl_rule_serializer import ACLRuleSerializer + + rule = AccessControlRule( + role=ACLRole.READER, + scope_type=ACLScopeType.USER, + scope_value='friend@gmail.com', + ) + + ACLRuleSerializer.to_json(rule) + + +.. code-block:: javascript + + { + 'role': 'reader', + 'scope': { + 'type': 'user', + 'value': 'friend@gmail.com' + } + } + + +To object +--------- + +.. code-block:: python + + acl_rule_json = { + 'role': 'reader', + 'scope': { + 'type': 'user', + 'value': 'friend@gmail.com' + }, + 'id': 'user:friend@gmail.com' + } + + ACLRuleSerializer.to_object(acl_rule_json) + +.. code-block:: python + + + + + +FreeBusy serializer +~~~~~~~~~~~~~~~~~~~ + +To json +------- + +.. code-block:: python + + from gcsa.free_busy import FreeBusy, TimeRange + from gcsa.serializers.free_busy_serializer import FreeBusySerializer + + free_busy = FreeBusy( + time_min=(24 / Mar / 2023)[13:22], + time_max=(25 / Mar / 2023)[13:22], + groups={'group1': ['calendar1', 'calendar2']}, + calendars={ + 'calendar1': [ + TimeRange((24 / Mar / 2023)[14:22], (24 / Mar / 2023)[15:22]), + TimeRange((24 / Mar / 2023)[17:22], (24 / Mar / 2023)[18:22]), + ], + 'calendar2': [ + TimeRange((24 / Mar / 2023)[15:22], (24 / Mar / 2023)[16:22]), + TimeRange((24 / Mar / 2023)[18:22], (24 / Mar / 2023)[19:22]), + ] + }, + groups_errors={ + "non-existing-group": [ + { + "domain": "global", + "reason": "notFound" + } + ] + }, + calendars_errors={ + "non-existing-calendar": [ + { + "domain": "global", + "reason": "notFound" + } + ] + } + ) + + FreeBusySerializer.to_json(free_busy) + + +.. code-block:: javascript + + { + 'calendars': { + 'calendar1': { + 'busy': [ + {'start': '2023-03-24T14:22:00', 'end': '2023-03-24T15:22:00'}, + {'start': '2023-03-24T17:22:00', 'end': '2023-03-24T18:22:00'} + ], + 'errors': [] + }, + 'calendar2': { + 'busy': [ + {'start': '2023-03-24T15:22:00', 'end': '2023-03-24T16:22:00'}, + {'start': '2023-03-24T18:22:00', 'end': '2023-03-24T19:22:00'} + ], + 'errors': [] + }, + 'non-existing-calendar': { + 'busy': [], + 'errors': [ + {'domain': 'global', 'reason': 'notFound'} + ] + } + }, + 'groups': { + 'group1': { + 'calendars': ['calendar1', 'calendar2'], + 'errors': [] + }, + 'non-existing-group': { + 'calendars': [], + 'errors': [ + {'domain': 'global', 'reason': 'notFound'} + ] + } + }, + 'timeMin': '2023-03-24T13:22:00', + 'timeMax': '2023-03-25T13:22:00' + } + + +To object +--------- + +.. code-block:: python + + free_busy_json = { + 'calendars': { + 'calendar1': { + 'busy': [ + {'start': '2023-03-24T14:22:00', 'end': '2023-03-24T15:22:00'}, + {'start': '2023-03-24T17:22:00', 'end': '2023-03-24T18:22:00'} + ], + 'errors': [] + }, + 'calendar2': { + 'busy': [ + {'start': '2023-03-24T15:22:00', 'end': '2023-03-24T16:22:00'}, + {'start': '2023-03-24T18:22:00', 'end': '2023-03-24T19:22:00'} + ], + 'errors': [] + }, + 'non-existing-calendar': { + 'busy': [], + 'errors': [ + {'domain': 'global', 'reason': 'notFound'} + ] + } + }, + 'groups': { + 'group1': { + 'calendars': ['calendar1', 'calendar2'], + 'errors': [] + }, + 'non-existing-group': { + 'calendars': [], + 'errors': [ + {'domain': 'global', 'reason': 'notFound'} + ] + } + }, + 'timeMin': '2023-03-24T13:22:00', + 'timeMax': '2023-03-25T13:22:00' + } + + FreeBusySerializer.to_object(free_busy_json) + +.. code-block:: python + + + + + +.. _`official API documentation`: https://developers.google.com/calendar diff --git a/google-calendar-simple-api/docs/source/settings.rst b/google-calendar-simple-api/docs/source/settings.rst new file mode 100644 index 0000000000000000000000000000000000000000..6c5e014fdf6fec19366c99576f84f195128b2c54 --- /dev/null +++ b/google-calendar-simple-api/docs/source/settings.rst @@ -0,0 +1,43 @@ +.. _settings: + +Settings +======== + +You can retrieve user's settings for the given account with :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_settings`. + +To do so, create a :py:class:`~gcsa.google_calendar.GoogleCalendar` instance (see :ref:`getting_started` to get your +credentials): + +.. code-block:: python + + from gcsa.google_calendar import GoogleCalendar + + gc = GoogleCalendar() + + +Following code will return a corresponding :py:class:`~gcsa.settings.Settings` object: + +.. code-block:: python + + from gcsa.google_calendar import GoogleCalendar + + gc = GoogleCalendar() + settings = gc.get_settings() + print(settings) + +.. code-block:: python + + User settings: + auto_add_hangouts=true + date_field_order=DMY + default_event_length=60 + format24_hour_time=false + hide_invitations=false + hide_weekends=false + locale=en + remind_on_responded_events_only=false + show_declined_events=true + timezone=Europe/Prague + use_keyboard_shortcuts=true + week_start=1 + diff --git a/google-calendar-simple-api/docs/source/why_gcsa.rst b/google-calendar-simple-api/docs/source/why_gcsa.rst new file mode 100644 index 0000000000000000000000000000000000000000..d30d392f800ee8d6178da5b1394d130399e02fe6 --- /dev/null +++ b/google-calendar-simple-api/docs/source/why_gcsa.rst @@ -0,0 +1,55 @@ +Why GCSA? +========= + +.. image:: _static/push_ups.webp + :width: 200 + :alt: 50 push-ups in one month + :align: right + + +I found that picture "The 50 push-ups in a month challenge" back in 2017 and decided it was time to try it. + +I wanted a calendar reminder of how many push-ups I need to do every day. As a developer, I couldn't afford +to spend *10 minutes* putting the events manually. So I spent *3 hours* getting the official API to work to do this +for me. Then I thought that this simple task shouldn't take *3 hours* and have spent the next *couple of days* +implementing the initial version of the gcsa. Several years later, I'm happy that people find this project useful. + + +If you'd like to try this yourself, here's the code you need: + +.. code-block:: python + + from gcsa.google_calendar import GoogleCalendar + from gcsa.event import Event + from beautiful_date import D, drange, days, MO + + gc = GoogleCalendar() + + PUSH_UPS_COUNT = [ + 5, 5, 0, 5, 10, 0, 10, + 0, 12, 12, 0, 15, 15, 0, + 20, 24, 0, 25, 30, 0, 32, + 35, 35, 0, 38, 40, 0, 42, + 45, 50 + ] + + # starting next Monday (of course) + # +1 days for the case that today is Monday + start = D.today()[9:00] + 1 * days + MO + end = start + len(PUSH_UPS_COUNT) * days + + for day, push_ups in zip(drange(start, end), PUSH_UPS_COUNT): + e = Event( + f'{push_ups} Push-Ups' if push_ups else 'Rest', + start=day, + minutes_before_popup_reminder=5 + ) + gc.add_event(e) + + + +Needless to say, I can't do 50 push-ups. + +Let me know in Discord_ if you've tried it. + +.. _Discord: https://discord.gg/mRAegbwYKS diff --git a/google-calendar-simple-api/gcsa.egg-info/PKG-INFO b/google-calendar-simple-api/gcsa.egg-info/PKG-INFO new file mode 100644 index 0000000000000000000000000000000000000000..37ab7b89e3779f7022ea93b86bc590f1d9eb4285 --- /dev/null +++ b/google-calendar-simple-api/gcsa.egg-info/PKG-INFO @@ -0,0 +1,121 @@ +Metadata-Version: 2.1 +Name: gcsa +Version: 2.3.0 +Summary: Simple API for Google Calendar management +Home-page: https://github.com/kuzmoyev/google-calendar-simple-api +Author: Yevhen Kuzmovych +Author-email: kuzmovych.yevhen@gmail.com +License: MIT +Keywords: python conference calendar hangouts python-library event conferences google-calendar pip recurrence google-calendar-api attendee gcsa +Platform: UNKNOWN +Classifier: License :: OSI Approved :: MIT License +Classifier: Natural Language :: English +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.5 +Classifier: Programming Language :: Python :: 3.6 +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Provides-Extra: dev +Provides-Extra: tests +Provides-Extra: docs +License-File: LICENSE + +Google Calendar Simple API +========================== + +.. image:: https://badge.fury.io/py/gcsa.svg + :target: https://badge.fury.io/py/gcsa + :alt: PyPi Package + +.. image:: https://readthedocs.org/projects/google-calendar-simple-api/badge/?version=latest + :target: https://google-calendar-simple-api.readthedocs.io/en/latest/?badge=latest + :alt: Documentation Status + +.. image:: https://github.com/kuzmoyev/Google-Calendar-Simple-API/workflows/Tests/badge.svg + :target: https://github.com/kuzmoyev/Google-Calendar-Simple-API/actions + :alt: Tests + +.. image:: https://badgen.net/badge/icon/discord?icon=discord&label + :target: https://discord.gg/mRAegbwYKS + :alt: Discord + + +`Google Calendar Simple API` or `gcsa` is a library that simplifies event and calendar management in Google Calendars. +It is a Pythonic object oriented adapter for the official API. See the full `documentation`_. + +Installation +------------ + +.. code-block:: bash + + pip install gcsa + +See `Getting started page`_ for more details and installation options. + +Example usage +------------- + +List events +~~~~~~~~~~~ + +.. code-block:: python + + from gcsa.google_calendar import GoogleCalendar + + calendar = GoogleCalendar('your_email@gmail.com') + for event in calendar: + print(event) + + +Create event +~~~~~~~~~~~~ + +.. code-block:: python + + from gcsa.event import Event + + event = Event( + 'The Glass Menagerie', + start=datetime(2020, 7, 10, 19, 0), + location='ZÑhΓ…β„’ebskÑ 468/21', + minutes_before_popup_reminder=15 + ) + calendar.add_event(event) + + +Create recurring event +~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from gcsa.recurrence import Recurrence, DAILY + + event = Event( + 'Breakfast', + start=date(2020, 7, 16), + recurrence=Recurrence.rule(freq=DAILY) + ) + calendar.add_event(event) + + +**Suggestion**: use beautiful_date_ to create `date` and `datetime` objects in your +projects (*because its beautiful... just like you*). + + +References +---------- + +Template for `setup.py` was taken from `kennethreitz/setup.py`_ + + +.. _documentation: https://google-calendar-simple-api.readthedocs.io/en/latest/?badge=latest +.. _`Getting started page`: https://google-calendar-simple-api.readthedocs.io/en/latest/getting_started.html +.. _beautiful_date: https://github.com/kuzmoyev/beautiful-date +.. _`kennethreitz/setup.py`: https://github.com/kennethreitz/setup.py + + diff --git a/google-calendar-simple-api/gcsa.egg-info/SOURCES.txt b/google-calendar-simple-api/gcsa.egg-info/SOURCES.txt new file mode 100644 index 0000000000000000000000000000000000000000..db9f6acccb88fe155164eb27d1bc63f40f6d67ad --- /dev/null +++ b/google-calendar-simple-api/gcsa.egg-info/SOURCES.txt @@ -0,0 +1,47 @@ +LICENSE +README.rst +setup.py +gcsa/__init__.py +gcsa/_resource.py +gcsa/acl.py +gcsa/attachment.py +gcsa/attendee.py +gcsa/calendar.py +gcsa/conference.py +gcsa/event.py +gcsa/free_busy.py +gcsa/google_calendar.py +gcsa/person.py +gcsa/recurrence.py +gcsa/reminders.py +gcsa/settings.py +gcsa.egg-info/PKG-INFO +gcsa.egg-info/SOURCES.txt +gcsa.egg-info/dependency_links.txt +gcsa.egg-info/not-zip-safe +gcsa.egg-info/requires.txt +gcsa.egg-info/top_level.txt +gcsa/_services/__init__.py +gcsa/_services/acl_service.py +gcsa/_services/authentication.py +gcsa/_services/base_service.py +gcsa/_services/calendar_lists_service.py +gcsa/_services/calendars_service.py +gcsa/_services/colors_service.py +gcsa/_services/events_service.py +gcsa/_services/free_busy_service.py +gcsa/_services/settings_service.py +gcsa/serializers/__init__.py +gcsa/serializers/acl_rule_serializer.py +gcsa/serializers/attachment_serializer.py +gcsa/serializers/attendee_serializer.py +gcsa/serializers/base_serializer.py +gcsa/serializers/calendar_serializer.py +gcsa/serializers/conference_serializer.py +gcsa/serializers/event_serializer.py +gcsa/serializers/free_busy_serializer.py +gcsa/serializers/person_serializer.py +gcsa/serializers/reminder_serializer.py +gcsa/serializers/settings_serializer.py +gcsa/util/__init__.py +gcsa/util/date_time_util.py \ No newline at end of file diff --git a/google-calendar-simple-api/gcsa.egg-info/dependency_links.txt b/google-calendar-simple-api/gcsa.egg-info/dependency_links.txt new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/google-calendar-simple-api/gcsa.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/google-calendar-simple-api/gcsa.egg-info/not-zip-safe b/google-calendar-simple-api/gcsa.egg-info/not-zip-safe new file mode 100644 index 0000000000000000000000000000000000000000..d3f5a12faa99758192ecc4ed3fc22c9249232e86 --- /dev/null +++ b/google-calendar-simple-api/gcsa.egg-info/not-zip-safe @@ -0,0 +1 @@ + diff --git a/google-calendar-simple-api/gcsa.egg-info/requires.txt b/google-calendar-simple-api/gcsa.egg-info/requires.txt new file mode 100644 index 0000000000000000000000000000000000000000..8b73dc130a7c40a4350d91a16df9a75d30e3289d --- /dev/null +++ b/google-calendar-simple-api/gcsa.egg-info/requires.txt @@ -0,0 +1,34 @@ +tzlocal<5,>=4 +google-api-python-client>=1.8 +google-auth-httplib2>=0.0.4 +google-auth-oauthlib<2.0,>=0.5 +python-dateutil>=2.7 +beautiful_date>=2.0.0 + +[dev] +setuptools +pytest +pytest-pep8 +pytest-cov +pyfakefs +flake8 +pep8-naming +twine +tox +sphinx +sphinx-rtd-theme + +[docs] +sphinx +sphinx-rtd-theme + +[tests] +setuptools +pytest +pytest-pep8 +pytest-cov +pyfakefs +flake8 +pep8-naming +twine +tox diff --git a/google-calendar-simple-api/gcsa.egg-info/top_level.txt b/google-calendar-simple-api/gcsa.egg-info/top_level.txt new file mode 100644 index 0000000000000000000000000000000000000000..ec73a70c49a79169083b0bff8216416e5359d61a --- /dev/null +++ b/google-calendar-simple-api/gcsa.egg-info/top_level.txt @@ -0,0 +1 @@ +gcsa diff --git a/google-calendar-simple-api/gcsa/__init__.py b/google-calendar-simple-api/gcsa/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/google-calendar-simple-api/gcsa/_resource.py b/google-calendar-simple-api/gcsa/_resource.py new file mode 100644 index 0000000000000000000000000000000000000000..818e4a5ff926769847059ac0c777e7ad3e863baf --- /dev/null +++ b/google-calendar-simple-api/gcsa/_resource.py @@ -0,0 +1,8 @@ +from abc import ABC, abstractmethod + + +class Resource(ABC): + @property + @abstractmethod + def id(self): + pass diff --git a/google-calendar-simple-api/gcsa/_services/__init__.py b/google-calendar-simple-api/gcsa/_services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/google-calendar-simple-api/gcsa/_services/acl_service.py b/google-calendar-simple-api/gcsa/_services/acl_service.py new file mode 100644 index 0000000000000000000000000000000000000000..fb6f867d7d74a270ae18b14b36de44542203a772 --- /dev/null +++ b/google-calendar-simple-api/gcsa/_services/acl_service.py @@ -0,0 +1,143 @@ +from typing import Iterable, Union + +from gcsa._services.base_service import BaseService +from gcsa.acl import AccessControlRule +from gcsa.serializers.acl_rule_serializer import ACLRuleSerializer + + +class ACLService(BaseService): + """Access Control List management methods of the `GoogleCalendar`""" + + def get_acl_rules( + self, + calendar_id: str = None, + show_deleted: bool = False + ) -> Iterable[AccessControlRule]: + """Returns the rules in the access control list for the calendar. + + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + :param show_deleted: + Whether to include deleted ACLs in the result. Deleted ACLs are represented by role equal to "none". + Deleted ACLs will always be included if syncToken is provided. Optional. The default is False. + + :return: + Iterable of `AccessControlRule` objects + """ + calendar_id = calendar_id or self.default_calendar + yield from self._list_paginated( + self.service.acl().list, + serializer_cls=ACLRuleSerializer, + calendarId=calendar_id, + **{ + 'showDeleted': show_deleted, + } + ) + + def get_acl_rule( + self, + rule_id: str, + calendar_id: str = None + ) -> AccessControlRule: + """Returns an access control rule + + :param rule_id: + ACL rule identifier. + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + + :return: + The corresponding `AccessControlRule` object + """ + calendar_id = calendar_id or self.default_calendar + acl_rule_resource = self.service.acl().get( + calendarId=calendar_id, + ruleId=rule_id + ).execute() + return ACLRuleSerializer.to_object(acl_rule_resource) + + def add_acl_rule( + self, + acl_rule: AccessControlRule, + send_notifications: bool = True, + calendar_id: str = None + ): + """Adds access control rule + + :param acl_rule: + AccessControlRule object. + :param send_notifications: + Whether to send notifications about the calendar sharing change. The default is True. + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + + :return: + Created access control rule with id. + """ + calendar_id = calendar_id or self.default_calendar + body = ACLRuleSerializer.to_json(acl_rule) + acl_rule_json = self.service.acl().insert( + calendarId=calendar_id, + body=body, + sendNotifications=send_notifications + ).execute() + return ACLRuleSerializer.to_object(acl_rule_json) + + def update_acl_rule( + self, + acl_rule: AccessControlRule, + send_notifications: bool = True, + calendar_id: str = None + ): + """Updates given access control rule + + :param acl_rule: + AccessControlRule object. + :param send_notifications: + Whether to send notifications about the calendar sharing change. The default is True. + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + + :return: + Updated access control rule. + """ + calendar_id = calendar_id or self.default_calendar + acl_id = self._get_resource_id(acl_rule) + body = ACLRuleSerializer.to_json(acl_rule) + acl_json = self.service.acl().update( + calendarId=calendar_id, + ruleId=acl_id, + body=body, + sendNotifications=send_notifications + ).execute() + return ACLRuleSerializer.to_object(acl_json) + + def delete_acl_rule( + self, + acl_rule: Union[AccessControlRule, str], + calendar_id: str = None + ): + """Deletes access control rule. + + :param acl_rule: + Access control rule's ID or `AccessControlRule` object with set `acl_id`. + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + """ + calendar_id = calendar_id or self.default_calendar + acl_id = self._get_resource_id(acl_rule) + + self.service.acl().delete( + calendarId=calendar_id, + ruleId=acl_id + ).execute() diff --git a/google-calendar-simple-api/gcsa/_services/authentication.py b/google-calendar-simple-api/gcsa/_services/authentication.py new file mode 100644 index 0000000000000000000000000000000000000000..f348a3e8c8b66b59a5eec21efcbc9b53916d8a05 --- /dev/null +++ b/google-calendar-simple-api/gcsa/_services/authentication.py @@ -0,0 +1,144 @@ +import pickle +import os.path +import glob +from typing import List + +from googleapiclient import discovery +from google_auth_oauthlib.flow import InstalledAppFlow +from google.auth.transport.requests import Request +from google.auth.credentials import Credentials + + +class AuthenticatedService: + """Handles authentication of the `GoogleCalendar`""" + + _READ_WRITE_SCOPES = 'https://www.googleapis.com/auth/calendar' + _LIST_ORDERS = ("startTime", "updated") + + def __init__( + self, + *, + credentials: Credentials = None, + credentials_path: str = None, + token_path: str = None, + save_token: bool = True, + read_only: bool = False, + authentication_flow_host: str = 'localhost', + authentication_flow_port: int = 8080, + authentication_flow_bind_addr: str = None + ): + """ + Specify ``credentials`` to use in requests or ``credentials_path`` and ``token_path`` to get credentials from. + + :param credentials: + Credentials with token and refresh token. + If specified, ``credentials_path``, ``token_path``, and ``save_token`` are ignored. + If not specified, credentials are retrieved from "token.pickle" file (specified in ``token_path`` or + default path) or with authentication flow using secret from "credentials.json" ("client_secret_*.json") + (specified in ``credentials_path`` or default path) + :param credentials_path: + Path to "credentials.json" ("client_secret_*.json") file. + Default: ~/.credentials/credentials.json or ~/.credentials/client_secret*.json + :param token_path: + Existing path to load the token from, or path to save the token after initial authentication flow. + Default: "token.pickle" in the same directory as the credentials_path + :param save_token: + Whether to pickle token after authentication flow for future uses + :param read_only: + If require read only access. Default: False + :param authentication_flow_host: + Host to receive response during authentication flow + :param authentication_flow_port: + Port to receive response during authentication flow + :param authentication_flow_bind_addr: + Optional IP address for the redirect server to listen on when it is not the same as host + (e.g. in a container) + """ + + if credentials: + self.credentials = self._ensure_refreshed(credentials) + else: + credentials_path = credentials_path or self._get_default_credentials_path() + credentials_dir, credentials_file = os.path.split(credentials_path) + token_path = token_path or os.path.join(credentials_dir, 'token.pickle') + scopes = [self._READ_WRITE_SCOPES + ('.readonly' if read_only else '')] + + self.credentials = self._get_credentials( + token_path, + credentials_dir, + credentials_file, + scopes, + save_token, + authentication_flow_host, + authentication_flow_port, + authentication_flow_bind_addr + ) + + self.service = discovery.build('calendar', 'v3', credentials=self.credentials) + + @staticmethod + def _ensure_refreshed( + credentials: Credentials + ) -> Credentials: + if not credentials.valid and credentials.expired: + credentials.refresh(Request()) + return credentials + + @staticmethod + def _get_credentials( + token_path: str, + credentials_dir: str, + credentials_file: str, + scopes: List[str], + save_token: bool, + host: str, + port: int, + bind_addr: str + ) -> Credentials: + credentials = None + + if os.path.exists(token_path): + with open(token_path, 'rb') as token_file: + credentials = pickle.load(token_file) + + if not credentials or not credentials.valid: + if credentials and credentials.expired and credentials.refresh_token: + credentials.refresh(Request()) + else: + credentials_path = os.path.join(credentials_dir, credentials_file) + flow = InstalledAppFlow.from_client_secrets_file(credentials_path, scopes) + credentials = flow.run_local_server(host=host, port=port, bind_addr=bind_addr) + + if save_token: + with open(token_path, 'wb') as token_file: + pickle.dump(credentials, token_file) + + return credentials + + @staticmethod + def _get_default_credentials_path() -> str: + """Checks if `.credentials` folder in home directory exists and contains `credentials.json` or + `client_secret*.json` file. + + :raises ValueError: if `.credentials` folder does not exist, none of `credentials.json` or `client_secret*.json` + files do not exist, or there are multiple `client_secret*.json` files. + :return: expanded path to `credentials.json` or `client_secret*.json` file + """ + home_dir = os.path.expanduser('~') + credential_dir = os.path.join(home_dir, '.credentials') + if not os.path.exists(credential_dir): + raise FileNotFoundError(f'Default credentials directory "{credential_dir}" does not exist.') + credential_path = os.path.join(credential_dir, 'credentials.json') + if os.path.exists(credential_path): + return credential_path + else: + credentials_files = glob.glob(credential_dir + '/client_secret*.json') + if len(credentials_files) > 1: + raise ValueError(f"Multiple credential files found in {credential_dir}.\n" + f"Try specifying the credentials file, e.x.:\n" + f"GoogleCalendar(credentials_path='{credentials_files[0]}')") + elif not credentials_files: + raise FileNotFoundError(f'Credentials file (credentials.json or client_secret*.json)' + f'not found in the default path: "{credential_dir}".') + else: + return credentials_files[0] diff --git a/google-calendar-simple-api/gcsa/_services/base_service.py b/google-calendar-simple-api/gcsa/_services/base_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e31c4ccf3825a28ffb99922175fd1e49cec805c1 --- /dev/null +++ b/google-calendar-simple-api/gcsa/_services/base_service.py @@ -0,0 +1,61 @@ +from typing import Callable, Type, Union + +from gcsa._resource import Resource +from gcsa._services.authentication import AuthenticatedService + + +class BaseService(AuthenticatedService): + def __init__(self, default_calendar, *args, **kwargs): + """ + :param default_calendar: + Users email address or name/id of the calendar. Default: primary calendar of the user + + If user's email or "primary" is specified, then primary calendar of the user is used. + You don't need to specify this parameter in this case as it is a default behaviour. + + To use a different calendar you need to specify its id. + Go to calendar's `settings and sharing` -> `Integrate calendar` -> `Calendar ID`. + """ + super().__init__(*args, **kwargs) + self.default_calendar = default_calendar + + @staticmethod + def _list_paginated( + request_method: Callable, + serializer_cls: Type = None, + **kwargs + ): + page_token = None + while True: + response_json = request_method( + **kwargs, + pageToken=page_token + ).execute() + for item_json in response_json['items']: + if serializer_cls: + yield serializer_cls(item_json).get_object() + else: + yield item_json + page_token = response_json.get('nextPageToken') + if not page_token: + break + + @staticmethod + def _get_resource_id(resource: Union[Resource, str]): + """If `resource` is `Resource` returns its id. + If `resource` is string, returns `resource` itself. + + :raises: + ValueError: if `resource` is `Resource` object that doesn't have id + TypeError: if `resource` is neither `Resource` nor `str` + """ + if isinstance(resource, Resource): + if resource.id is None: + raise ValueError("Resource has to have id to be updated, moved or deleted.") + return resource.id + elif isinstance(resource, str): + return resource + else: + raise TypeError('"resource" object must be Resource or str, not {!r}'.format( + resource.__class__.__name__ + )) diff --git a/google-calendar-simple-api/gcsa/_services/calendar_lists_service.py b/google-calendar-simple-api/gcsa/_services/calendar_lists_service.py new file mode 100644 index 0000000000000000000000000000000000000000..b48bef6bfebe683b78bd02f65a42cceaa120f3da --- /dev/null +++ b/google-calendar-simple-api/gcsa/_services/calendar_lists_service.py @@ -0,0 +1,123 @@ +from typing import Iterable, Union + +from gcsa._services.base_service import BaseService +from gcsa.calendar import CalendarListEntry, Calendar +from gcsa.serializers.calendar_serializer import CalendarListEntrySerializer + + +class CalendarListService(BaseService): + """Calendar list management methods of the `GoogleCalendar`""" + + def get_calendar_list( + self, + min_access_role: str = None, + show_deleted: bool = False, + show_hidden: bool = False + ) -> Iterable[CalendarListEntry]: + """Returns the calendars on the user's calendar list. + + :param min_access_role: + The minimum access role for the user in the returned entries. See :py:class:`~gcsa.calendar.AccessRoles` + The default is no restriction. + :param show_deleted: + Whether to include deleted calendar list entries in the result. The default is False. + :param show_hidden: + Whether to show hidden entries. The default is False. + + :return: + Iterable of :py:class:`~gcsa.calendar.CalendarListEntry` objects. + """ + yield from self._list_paginated( + self.service.calendarList().list, + serializer_cls=CalendarListEntrySerializer, + minAccessRole=min_access_role, + showDeleted=show_deleted, + showHidden=show_hidden, + ) + + def get_calendar_list_entry( + self, + calendar_id: str = None + ) -> CalendarListEntry: + """Returns a calendar with the corresponding calendar_id from the user's calendar list. + + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar` + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + + :return: + The corresponding :py:class:`~gcsa.calendar.CalendarListEntry` object. + """ + calendar_id = calendar_id or self.default_calendar + calendar_resource = self.service.calendarList().get(calendarId=calendar_id).execute() + return CalendarListEntrySerializer.to_object(calendar_resource) + + def add_calendar_list_entry( + self, + calendar: CalendarListEntry, + color_rgb_format: bool = None + ) -> CalendarListEntry: + """Adds an existing calendar into the user's calendar list. + + :param calendar: + :py:class:`~gcsa.calendar.CalendarListEntry` object. + :param color_rgb_format: + Whether to use the `foreground_color` and `background_color` fields to write the calendar colors (RGB). + If this feature is used, the index-based `color_id` field will be set to the best matching option + automatically. The default is True if `foreground_color` or `background_color` is set, False otherwise. + + :return: + Created `CalendarListEntry` object with id. + """ + if color_rgb_format is None: + color_rgb_format = (calendar.foreground_color is not None) or (calendar.background_color is not None) + + body = CalendarListEntrySerializer.to_json(calendar) + calendar_json = self.service.calendarList().insert( + body=body, + colorRgbFormat=color_rgb_format + ).execute() + return CalendarListEntrySerializer.to_object(calendar_json) + + def update_calendar_list_entry( + self, + calendar: CalendarListEntry, + color_rgb_format: bool = None + ) -> CalendarListEntry: + """Updates an existing calendar on the user's calendar list. + + :param calendar: + :py:class:`~gcsa.calendar.Calendar` object with set `calendar_id` + :param color_rgb_format: + Whether to use the `foreground_color` and `background_color` fields to write the calendar colors (RGB). + If this feature is used, the index-based color_id field will be set to the best matching option + automatically. The default is True if `foreground_color` or `background_color` is set, False otherwise. + + :return: + Updated calendar list entry object + """ + calendar_id = self._get_resource_id(calendar) + if color_rgb_format is None: + color_rgb_format = calendar.foreground_color is not None or calendar.background_color is not None + + body = CalendarListEntrySerializer.to_json(calendar) + calendar_json = self.service.calendarList().update( + calendarId=calendar_id, + body=body, + colorRgbFormat=color_rgb_format + ).execute() + return CalendarListEntrySerializer.to_object(calendar_json) + + def delete_calendar_list_entry( + self, + calendar: Union[Calendar, CalendarListEntry, str] + ): + """Removes a calendar from the user's calendar list. + + :param calendar: + Calendar's ID or :py:class:`~gcsa.calendar.Calendar`/:py:class:`~gcsa.calendar.CalendarListEntry` object + with the set `calendar_id`. + """ + calendar_id = self._get_resource_id(calendar) + self.service.calendarList().delete(calendarId=calendar_id).execute() diff --git a/google-calendar-simple-api/gcsa/_services/calendars_service.py b/google-calendar-simple-api/gcsa/_services/calendars_service.py new file mode 100644 index 0000000000000000000000000000000000000000..c50c39b9bdd4534c010476208ffd382fd58e6c82 --- /dev/null +++ b/google-calendar-simple-api/gcsa/_services/calendars_service.py @@ -0,0 +1,102 @@ +from typing import Union + +from gcsa._services.base_service import BaseService +from gcsa.calendar import Calendar, CalendarListEntry +from gcsa.serializers.calendar_serializer import CalendarSerializer + + +class CalendarsService(BaseService): + """Calendars management methods of the `GoogleCalendar`""" + + def get_calendar( + self, + calendar_id: str = None + ) -> Calendar: + """Returns the calendar with the corresponding calendar_id. + + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + + :return: + The corresponding :py:class:`~gcsa.calendar.Calendar` object. + """ + calendar_id = calendar_id or self.default_calendar + calendar_resource = self.service.calendars().get( + calendarId=calendar_id + ).execute() + return CalendarSerializer.to_object(calendar_resource) + + def add_calendar( + self, + calendar: Calendar + ): + """Creates a secondary calendar. + + :param calendar: + Calendar object. + :return: + Created calendar object with ID. + """ + body = CalendarSerializer.to_json(calendar) + calendar_json = self.service.calendars().insert( + body=body + ).execute() + return CalendarSerializer.to_object(calendar_json) + + def update_calendar( + self, + calendar: Calendar + ): + """Updates metadata for a calendar. + + :param calendar: + Calendar object with set `calendar_id` + + :return: + Updated calendar object + """ + calendar_id = self._get_resource_id(calendar) + body = CalendarSerializer.to_json(calendar) + calendar_json = self.service.calendars().update( + calendarId=calendar_id, + body=body + ).execute() + return CalendarSerializer.to_object(calendar_json) + + def delete_calendar( + self, + calendar: Union[Calendar, CalendarListEntry, str] + ): + """Deletes a secondary calendar. + + Use :py:meth:`~gcsa.google_calendar.GoogleCalendar.clear_calendar` for clearing all events on primary calendars. + + :param calendar: + Calendar's ID or :py:class:`~gcsa.calendar.Calendar` object with set `calendar_id`. + """ + calendar_id = self._get_resource_id(calendar) + self.service.calendars().delete(calendarId=calendar_id).execute() + + def clear_calendar(self): + """Clears a **primary** calendar. + This operation deletes all events associated with the **primary** calendar of an account. + + Currently, there is no way to clear a secondary calendar. + You can use :py:meth:`~gcsa.google_calendar.GoogleCalendar.delete_event` method with the secondary calendar's ID + to delete events from a secondary calendar. + """ + self.service.calendars().clear(calendarId='primary').execute() + + def clear(self): + """Kept for back-compatibility. Use :py:meth:`~gcsa.google_calendar.GoogleCalendar.clear_calendar` instead. + + Clears a **primary** calendar. + This operation deletes all events associated with the **primary** calendar of an account. + + Currently, there is no way to clear a secondary calendar. + You can use :py:meth:`~gcsa.google_calendar.GoogleCalendar.delete_event` method with the secondary calendar's ID + to delete events from a secondary calendar. + """ + self.clear_calendar() diff --git a/google-calendar-simple-api/gcsa/_services/colors_service.py b/google-calendar-simple-api/gcsa/_services/colors_service.py new file mode 100644 index 0000000000000000000000000000000000000000..480513be8fd9cd52488ab881254889b8df9a3f32 --- /dev/null +++ b/google-calendar-simple-api/gcsa/_services/colors_service.py @@ -0,0 +1,15 @@ +from gcsa._services.base_service import BaseService + + +class ColorsService(BaseService): + """Colors management methods of the `GoogleCalendar`""" + + def list_event_colors(self) -> dict: + """A global palette of event colors, mapping from the color ID to its definition. + An :py:class:`~gcsa.event.Event` may refer to one of these color IDs in its color_id field.""" + return self.service.colors().get().execute()['event'] + + def list_calendar_colors(self) -> dict: + """A global palette of calendar colors, mapping from the color ID to its definition. + :py:class:`~gcsa.calendar.CalendarListEntry` resource refers to one of these color IDs in its color_id field.""" + return self.service.colors().get().execute()['calendar'] diff --git a/google-calendar-simple-api/gcsa/_services/events_service.py b/google-calendar-simple-api/gcsa/_services/events_service.py new file mode 100644 index 0000000000000000000000000000000000000000..af2bf49a18cce52da0bc3df793878e9c89b4a02d --- /dev/null +++ b/google-calendar-simple-api/gcsa/_services/events_service.py @@ -0,0 +1,427 @@ +from datetime import date, datetime +from typing import Union, Iterator, Iterable, Callable + +from beautiful_date import BeautifulDate +from dateutil.relativedelta import relativedelta +from tzlocal import get_localzone_name + +from gcsa._services.base_service import BaseService +from gcsa.event import Event +from gcsa.serializers.event_serializer import EventSerializer +from gcsa.util.date_time_util import to_localized_iso + + +class SendUpdatesMode: + """Possible values of the mode for sending updates or invitations to attendees. + + * ALL - Send updates to all participants. This is the default value. + * EXTERNAL_ONLY - Send updates only to attendees not using google calendar. + * NONE - Do not send updates. + """ + + ALL = "all" + EXTERNAL_ONLY = "externalOnly" + NONE = "none" + + +class EventsService(BaseService): + """Event management methods of the `GoogleCalendar`""" + + def _list_events( + self, + request_method: Callable, + time_min: Union[date, datetime, BeautifulDate], + time_max: Union[date, datetime, BeautifulDate], + timezone: str, + calendar_id: str, + **kwargs + ) -> Iterable[Event]: + """Lists paginated events received from request_method.""" + + time_min = time_min or datetime.now() + time_max = time_max or time_min + relativedelta(years=1) + + time_min = to_localized_iso(time_min, timezone) + time_max = to_localized_iso(time_max, timezone) + + yield from self._list_paginated( + request_method, + serializer_cls=EventSerializer, + calendarId=calendar_id, + timeMin=time_min, + timeMax=time_max, + **kwargs + ) + + def get_events( + self, + time_min: Union[date, datetime, BeautifulDate] = None, + time_max: Union[date, datetime, BeautifulDate] = None, + order_by: str = None, + timezone: str = get_localzone_name(), + single_events: bool = False, + query: str = None, + calendar_id: str = None, + **kwargs + ) -> Iterable[Event]: + """Lists events. + + :param time_min: + Staring date/datetime + :param time_max: + Ending date/datetime + :param order_by: + Order of the events. Possible values: "startTime", "updated". Default is unspecified stable order. + :param timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". By default, + the computers local timezone is used if it is configured. UTC is used otherwise. + :param single_events: + Whether to expand recurring events into instances and only return single one-off events and + instances of recurring events, but not the underlying recurring events themselves. + :param query: + Free text search terms to find events that match these terms in any field, except for + extended properties. + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + :param kwargs: + Additional API parameters. + See https://developers.google.com/calendar/v3/reference/events/list#optional-parameters + + :return: + Iterable of `Event` objects + """ + calendar_id = calendar_id or self.default_calendar + if not single_events and order_by == 'startTime': + raise ValueError( + '"startTime" ordering is only available when querying single events, i.e. single_events=True' + ) + yield from self._list_events( + self.service.events().list, + time_min=time_min, + time_max=time_max, + timezone=timezone, + calendar_id=calendar_id, + **{ + 'singleEvents': single_events, + 'orderBy': order_by, + 'q': query, + **kwargs + } + ) + + def get_instances( + self, + recurring_event: Union[Event, str], + time_min: Union[date, datetime, BeautifulDate] = None, + time_max: Union[date, datetime, BeautifulDate] = None, + timezone: str = get_localzone_name(), + calendar_id: str = None, + **kwargs + ) -> Iterable[Event]: + """Lists instances of recurring event + + :param recurring_event: + Recurring event or instance of recurring event (`Event` object) or id of the recurring event + :param time_min: + Staring date/datetime + :param time_max: + Ending date/datetime + :param timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". By default, + the computers local timezone is used if it is configured. UTC is used otherwise. + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + :param kwargs: + Additional API parameters. + See https://developers.google.com/calendar/v3/reference/events/instances#optional-parameters + + :return: + Iterable of event objects + """ + calendar_id = calendar_id or self.default_calendar + try: + event_id = self._get_resource_id(recurring_event) + except ValueError: + raise ValueError("Recurring event has to have id to retrieve its instances.") + + yield from self._list_events( + self.service.events().instances, + time_min=time_min, + time_max=time_max, + timezone=timezone, + calendar_id=calendar_id, + **{ + 'eventId': event_id, + **kwargs + } + ) + + def __iter__(self) -> Iterator[Event]: + return iter(self.get_events()) + + def __getitem__(self, r): + if isinstance(r, slice): + time_min, time_max, order_by = r.start or None, r.stop or None, r.step or None + elif isinstance(r, (date, datetime)): + time_min, time_max, order_by = r, None, None + else: + raise NotImplementedError + + if ( + (time_min and not isinstance(time_min, (date, datetime))) + or (time_max and not isinstance(time_max, (date, datetime))) + or (order_by and (not isinstance(order_by, str) or order_by not in self._LIST_ORDERS)) + ): + raise ValueError('Calendar indexing is in the following format: time_min[:time_max[:order_by]],' + ' where time_min and time_max are date/datetime objects' + ' and order_by is None or one of "startTime" or "updated" strings.') + + return self.get_events(time_min, time_max, order_by=order_by, single_events=(order_by == "startTime")) + + def get_event( + self, + event_id: str, + calendar_id: str = None, + **kwargs + ) -> Event: + """Returns the event with the corresponding event_id. + + :param event_id: + The unique event ID. + :param kwargs: + Additional API parameters. + See https://developers.google.com/calendar/v3/reference/events/get#optional-parameters + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + + :return: + The corresponding event object. + """ + calendar_id = calendar_id or self.default_calendar + event_resource = self.service.events().get( + calendarId=calendar_id, + eventId=event_id, + **kwargs + ).execute() + return EventSerializer.to_object(event_resource) + + def add_event( + self, + event: Event, + send_updates: str = SendUpdatesMode.NONE, + calendar_id: str = None, + **kwargs + ) -> Event: + """Creates event in the calendar + + :param event: + Event object. + :param send_updates: + Whether and how to send updates to attendees. See :py:class:`~gcsa.google_calendar.SendUpdatesMode` + Default is "NONE". + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + :param kwargs: + Additional API parameters. + See https://developers.google.com/calendar/v3/reference/events/insert#optional-parameters + + :return: + Created event object with id. + """ + calendar_id = calendar_id or self.default_calendar + body = EventSerializer.to_json(event) + event_json = self.service.events().insert( + calendarId=calendar_id, + body=body, + conferenceDataVersion=1, + sendUpdates=send_updates, + **kwargs + ).execute() + return EventSerializer.to_object(event_json) + + def add_quick_event( + self, + event_string: str, + send_updates: str = SendUpdatesMode.NONE, + calendar_id: str = None, + **kwargs + ) -> Event: + """Creates event in the calendar by string description. + + Example: + Appointment at Somewhere on June 3rd 10am-10:25am + + :param event_string: + String that describes an event + :param send_updates: + Whether and how to send updates to attendees. See :py:class:`~gcsa.google_calendar.SendUpdatesMode` + Default is "NONE". + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + :param kwargs: + Additional API parameters. + See https://developers.google.com/calendar/v3/reference/events/quickAdd#optional-parameters + + :return: + Created event object with id. + """ + calendar_id = calendar_id or self.default_calendar + event_json = self.service.events().quickAdd( + calendarId=calendar_id, + text=event_string, + sendUpdates=send_updates, + **kwargs + ).execute() + return EventSerializer.to_object(event_json) + + def update_event( + self, + event: Event, + send_updates: str = SendUpdatesMode.NONE, + calendar_id: str = None, + **kwargs + ) -> Event: + """Updates existing event in the calendar + + :param event: + Event object with set `event_id`. + :param send_updates: + Whether and how to send updates to attendees. See :py:class:`~gcsa.google_calendar.SendUpdatesMode` + Default is "NONE". + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + :param kwargs: + Additional API parameters. + See https://developers.google.com/calendar/v3/reference/events/update#optional-parameters + + :return: + Updated event object. + """ + calendar_id = calendar_id or self.default_calendar + event_id = self._get_resource_id(event) + body = EventSerializer.to_json(event) + event_json = self.service.events().update( + calendarId=calendar_id, + eventId=event_id, + body=body, + conferenceDataVersion=1, + sendUpdates=send_updates, + **kwargs + ).execute() + return EventSerializer.to_object(event_json) + + def import_event( + self, + event: Event, + calendar_id: str = None, + **kwargs + ) -> Event: + """Imports an event in the calendar + + This operation is used to add a private copy of an existing event to a calendar. + + :param event: + Event object. + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + :param kwargs: + Additional API parameters. + See https://developers.google.com/calendar/v3/reference/events/import#optional-parameters + + :return: + Created event object with id. + """ + calendar_id = calendar_id or self.default_calendar + body = EventSerializer.to_json(event) + event_json = self.service.events().import_( + calendarId=calendar_id, + body=body, + conferenceDataVersion=1, + **kwargs + ).execute() + return EventSerializer.to_object(event_json) + + def move_event( + self, + event: Event, + destination_calendar_id: str, + send_updates: str = SendUpdatesMode.NONE, + source_calendar_id: str = None, + **kwargs + ) -> Event: + """Moves existing event from calendar to another calendar + + :param event: + Event object with set event_id. + :param destination_calendar_id: + ID of the destination calendar. + :param send_updates: + Whether and how to send updates to attendees. See :py:class:`~gcsa.google_calendar.SendUpdatesMode` + Default is "NONE". + :param source_calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + :param kwargs: + Additional API parameters. + See https://developers.google.com/calendar/v3/reference/events/move#optional-parameters + + :return: + Moved event object. + """ + source_calendar_id = source_calendar_id or self.default_calendar + event_id = self._get_resource_id(event) + moved_event_json = self.service.events().move( + calendarId=source_calendar_id, + eventId=event_id, + destination=destination_calendar_id, + sendUpdates=send_updates, + **kwargs + ).execute() + return EventSerializer.to_object(moved_event_json) + + def delete_event( + self, + event: Union[Event, str], + send_updates: str = SendUpdatesMode.NONE, + calendar_id: str = None, + **kwargs + ): + """Deletes an event. + + :param event: + Event's ID or `Event` object with set `event_id`. + :param send_updates: + Whether and how to send updates to attendees. See :py:class:`~gcsa.google_calendar.SendUpdatesMode` + Default is "NONE". + :param calendar_id: + Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword. + :param kwargs: + Additional API parameters. + See https://developers.google.com/calendar/v3/reference/events/delete#optional-parameters + """ + calendar_id = calendar_id or self.default_calendar + event_id = self._get_resource_id(event) + + self.service.events().delete( + calendarId=calendar_id, + eventId=event_id, + sendUpdates=send_updates, + **kwargs + ).execute() diff --git a/google-calendar-simple-api/gcsa/_services/free_busy_service.py b/google-calendar-simple-api/gcsa/_services/free_busy_service.py new file mode 100644 index 0000000000000000000000000000000000000000..3e906f429cf2dae4730d34a1f1d8821043660175 --- /dev/null +++ b/google-calendar-simple-api/gcsa/_services/free_busy_service.py @@ -0,0 +1,87 @@ +from datetime import date, datetime +from typing import Union, List + +from beautiful_date import BeautifulDate +from dateutil.relativedelta import relativedelta +from tzlocal import get_localzone_name + +from gcsa._services.base_service import BaseService +from gcsa.free_busy import FreeBusy, FreeBusyQueryError +from gcsa.serializers.free_busy_serializer import FreeBusySerializer +from gcsa.util.date_time_util import to_localized_iso + + +class FreeBusyService(BaseService): + def get_free_busy( + self, + resource_ids: Union[str, List[str]] = None, + *, + time_min: Union[date, datetime, BeautifulDate] = None, + time_max: Union[date, datetime, BeautifulDate] = None, + timezone: str = get_localzone_name(), + group_expansion_max: int = None, + calendar_expansion_max: int = None, + ignore_errors: bool = False + ) -> FreeBusy: + """Returns free/busy information for a set of calendars and/or groups. + + :param resource_ids: + Identifier or list of identifiers of calendar(s) and/or group(s). + Default is `default_calendar` specified in `GoogleCalendar`. + :param time_min: + The start of the interval for the query. + :param time_max: + The end of the interval for the query. + :param timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". By default, + the computers local timezone is used if it is configured. UTC is used otherwise. + :param group_expansion_max: + Maximal number of calendar identifiers to be provided for a single group. + An error is returned for a group with more members than this value. + Maximum value is 100. + :param calendar_expansion_max: + Maximal number of calendars for which FreeBusy information is to be provided. + Maximum value is 50. + :param ignore_errors: + Whether errors related to calendars and/or groups should be ignored. + If `False` :py:class:`~gcsa.free_busy.FreeBusyQueryError` is raised in case of query related errors. + If `True`, related errors are stored in the resulting :py:class:`~gcsa.free_busy.FreeBusy` object. + Default is `False`. + Note, request related errors (e.x. authentication error) will not be ignored regardless of + the `ignore_errors` value. + + :return: + :py:class:`~gcsa.free_busy.FreeBusy` object. + """ + + time_min = time_min or datetime.now() + time_max = time_max or time_min + relativedelta(weeks=2) + + time_min = to_localized_iso(time_min, timezone) + time_max = to_localized_iso(time_max, timezone) + + if resource_ids is None: + resource_ids = [self.default_calendar] + elif not isinstance(resource_ids, (list, tuple, set)): + resource_ids = [resource_ids] + + body = { + "timeMin": time_min, + "timeMax": time_max, + "timeZone": timezone, + "groupExpansionMax": group_expansion_max, + "calendarExpansionMax": calendar_expansion_max, + "items": [ + { + "id": r_id + } for r_id in resource_ids + ] + } + + free_busy_json = self.service.freebusy().query(body=body).execute() + free_busy = FreeBusySerializer.to_object(free_busy_json) + if not ignore_errors and (free_busy.groups_errors or free_busy.calendars_errors): + raise FreeBusyQueryError(groups_errors=free_busy.groups_errors, + calendars_errors=free_busy.calendars_errors) + + return free_busy diff --git a/google-calendar-simple-api/gcsa/_services/settings_service.py b/google-calendar-simple-api/gcsa/_services/settings_service.py new file mode 100644 index 0000000000000000000000000000000000000000..91b6cee59ff8e322d6396984c02bc55dcf15893f --- /dev/null +++ b/google-calendar-simple-api/gcsa/_services/settings_service.py @@ -0,0 +1,13 @@ +from gcsa._services.base_service import BaseService +from gcsa.serializers.settings_serializer import SettingsSerializer +from gcsa.settings import Settings + + +class SettingsService(BaseService): + """Settings management methods of the `GoogleCalendar`""" + + def get_settings(self) -> Settings: + """Returns user settings for the authenticated user.""" + settings_list = list(self._list_paginated(self.service.settings().list)) + settings_json = {s['id']: s['value'] for s in settings_list} + return SettingsSerializer.to_object(settings_json) diff --git a/google-calendar-simple-api/gcsa/acl.py b/google-calendar-simple-api/gcsa/acl.py new file mode 100644 index 0000000000000000000000000000000000000000..d9fb418c48fa0bc8730343f565f6acd3dc8f781e --- /dev/null +++ b/google-calendar-simple-api/gcsa/acl.py @@ -0,0 +1,70 @@ +from gcsa._resource import Resource + + +class ACLRole: + """ + * `NONE` - Provides no access. + * `FREE_BUSY_READER` - Provides read access to free/busy information. + * `READER` - Provides read access to the calendar. Private events will appear to users with reader access, but event + details will be hidden. + * `WRITER` - Provides read and write access to the calendar. Private events will appear to users with writer access, + and event details will be visible. + * `OWNER` - Provides ownership of the calendar. This role has all of the permissions of the writer role with + the additional ability to see and manipulate ACLs. + """ + + NONE = "none" + FREE_BUSY_READER = "freeBusyReader" + READER = "reader" + WRITER = "writer" + OWNER = "owner" + + +class ACLScopeType: + """ + * `DEFAULT` - The public scope. + * `USER` - Limits the scope to a single user. + * `GROUP` - Limits the scope to a group. + * `DOMAIN` - Limits the scope to a domain. + """ + + DEFAULT = "default" + USER = "user" + GROUP = "group" + DOMAIN = "domain" + + +class AccessControlRule(Resource): + def __init__( + self, + *, + role: str, + scope_type: str, + acl_id: str = None, + scope_value: str = None + ): + """ + :param role: + The role assigned to the scope. See :py:class:`~gcsa.acl.ACLRole`. + :param scope_type: + The type of the scope. See :py:class:`~gcsa.acl.ACLScopeType`. + :param acl_id: + Identifier of the Access Control List (ACL) rule. + :param scope_value: + The email address of a user or group, or the name of a domain, depending on the scope type. + Omitted for type "default". + """ + self.acl_id = acl_id + self.role = role + self.scope_type = scope_type + self.scope_value = scope_value + + @property + def id(self): + return self.acl_id + + def __str__(self): + return '{} - {}'.format(self.scope_value, self.role) + + def __repr__(self): + return ''.format(self.__str__()) diff --git a/google-calendar-simple-api/gcsa/attachment.py b/google-calendar-simple-api/gcsa/attachment.py new file mode 100644 index 0000000000000000000000000000000000000000..ce944712ffd210d1d7f594384bb0c0b15970a182 --- /dev/null +++ b/google-calendar-simple-api/gcsa/attachment.py @@ -0,0 +1,72 @@ +class Attachment: + _SUPPORTED_MIME_TYPES = { + "application/vnd.google-apps.audio", + "application/vnd.google-apps.document", # Google Docs + "application/vnd.google-apps.drawing", # Google Drawing + "application/vnd.google-apps.file", # Google Drive file + "application/vnd.google-apps.folder", # Google Drive folder + "application/vnd.google-apps.form", # Google Forms + "application/vnd.google-apps.fusiontable", # Google Fusion Tables + "application/vnd.google-apps.map", # Google My Maps + "application/vnd.google-apps.photo", + "application/vnd.google-apps.presentation", # Google Slides + "application/vnd.google-apps.script", # Google Apps Scripts + "application/vnd.google-apps.site", # Google Sites + "application/vnd.google-apps.spreadsheet", # Google Sheets + "application/vnd.google-apps.unknown", + "application/vnd.google-apps.video", + "application/vnd.google-apps.drive-sdk" # 3rd party shortcut + } + + def __init__( + self, + file_url: str, + title: str = None, + mime_type: str = None, + _icon_link: str = None, + _file_id: str = None + ): + """File attachment for the event. + + Currently only Google Drive attachments are supported. + + :param file_url: + A link for opening the file in a relevant Google editor or viewer. + :param title: + Attachment title + :param mime_type: + Internet media type (MIME type) of the attachment. See `available MIME types`_ + :param _icon_link: + URL link to the attachment's icon (read only) + :param _file_id: + Id of the attached file (read only) + + .. note: "read only" means that Attachment has given property only + when received from the existing event in the calendar. + + .. _`available MIME types`: https://developers.google.com/drive/api/v3/mime-types + """ + + self.unsupported_mime_type = mime_type not in Attachment._SUPPORTED_MIME_TYPES + + self.file_url = file_url + self.title = title + self.mime_type = mime_type + self.icon_link = _icon_link + self.file_id = _file_id + + def __eq__(self, other): + return ( + isinstance(other, Attachment) + and self.file_url == other.file_url + and self.title == other.title + and self.mime_type == other.mime_type + and self.icon_link == other.icon_link + and self.file_id == other.file_id + ) + + def __str__(self): + return "'{}' - '{}'".format(self.title, self.file_url) + + def __repr__(self): + return ''.format(self.__str__()) diff --git a/google-calendar-simple-api/gcsa/attendee.py b/google-calendar-simple-api/gcsa/attendee.py new file mode 100644 index 0000000000000000000000000000000000000000..f4d0a60c7c98243d36dc0a9e3648651c24d6c300 --- /dev/null +++ b/google-calendar-simple-api/gcsa/attendee.py @@ -0,0 +1,79 @@ +from .person import Person + + +class ResponseStatus: + """Possible values for attendee's response status + + * NEEDS_ACTION - The attendee has not responded to the invitation. + * DECLINED - The attendee has declined the invitation. + * TENTATIVE - The attendee has tentatively accepted the invitation. + * ACCEPTED - The attendee has accepted the invitation. + """ + NEEDS_ACTION = "needsAction" + DECLINED = "declined" + TENTATIVE = "tentative" + ACCEPTED = "accepted" + + +class Attendee(Person): + def __init__( + self, + email: str, + display_name: str = None, + comment: str = None, + optional: bool = None, + is_resource: bool = None, + additional_guests: int = None, + _id: str = None, + _is_self: bool = None, + _response_status: str = None + ): + """Represents attendee of the event. + + :param email: + The attendee's email address, if available. + :param display_name: + The attendee's name, if available + :param comment: + The attendee's response comment + :param optional: + Whether this is an optional attendee. The default is False. + :param is_resource: + Whether the attendee is a resource. + Can only be set when the attendee is added to the event + for the first time. Subsequent modifications are ignored. + The default is False. + :param additional_guests: + Number of additional guests. The default is 0. + :param _id: + The attendee's Profile ID, if available. + It corresponds to the id field in the People collection of the Google+ API + :param _is_self: + Whether this entry represents the calendar on which this copy of the event appears. + The default is False (set by Google's API). + :param _response_status: + The attendee's response status. See :py:class:`~gcsa.attendee.ResponseStatus` + """ + super().__init__(email=email, display_name=display_name, _id=_id, _is_self=_is_self) + self.comment = comment + self.optional = optional + self.is_resource = is_resource + self.additional_guests = additional_guests + self.response_status = _response_status + + def __eq__(self, other): + return ( + isinstance(other, Attendee) + and super().__eq__(other) + and self.comment == other.comment + and self.optional == other.optional + and self.is_resource == other.is_resource + and self.additional_guests == other.additional_guests + and self.response_status == other.response_status + ) + + def __str__(self): + return "'{}' - response: '{}'".format(self.email, self.response_status) + + def __repr__(self): + return ''.format(self.__str__()) diff --git a/google-calendar-simple-api/gcsa/calendar.py b/google-calendar-simple-api/gcsa/calendar.py new file mode 100644 index 0000000000000000000000000000000000000000..1cf848e1444a6bf589dac7592e6b2a5f4d64eeba --- /dev/null +++ b/google-calendar-simple-api/gcsa/calendar.py @@ -0,0 +1,267 @@ +from typing import List + +from tzlocal import get_localzone_name + +from ._resource import Resource +from .reminders import Reminder + + +class NotificationType: + """ + * `EVENT_CREATION` - Notification sent when a new event is put on the calendar. + * `EVENT_CHANGE` - Notification sent when an event is changed. + * `EVENT_CANCELLATION` - Notification sent when an event is cancelled. + * `EVENT_RESPONSE` - Notification sent when an attendee responds to the event invitation. + * `AGENDA` - An agenda with the events of the day (sent out in the morning). + """ + + EVENT_CREATION = "eventCreation" + EVENT_CHANGE = "eventChange" + EVENT_CANCELLATION = "eventCancellation" + EVENT_RESPONSE = "eventResponse" + AGENDA = "agenda" + + +class AccessRoles: + """ + * `FREE_BUSY_READER` - Provides read access to free/busy information. + * `READER` - Provides read access to the calendar. + Private events will appear to users with reader access, but event details will be hidden. + * `WRITER` - Provides read and write access to the calendar. + Private events will appear to users with writer access, and event details will be visible. + * `OWNER` - Provides ownership of the calendar. + This role has all of the permissions of the writer role with the additional ability to see and manipulate ACLs. + """ + + FREE_BUSY_READER = "freeBusyReader" + READER = "reader" + WRITER = "writer" + OWNER = "owner" + + +class Calendar(Resource): + def __init__( + self, + summary: str, + *, + calendar_id: str = None, + description: str = None, + location: str = None, + timezone: str = get_localzone_name(), + allowed_conference_solution_types: List[str] = None + ): + """ + :param summary: + Title of the calendar. + :param calendar_id: + Identifier of the calendar. + To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`. + :param description: + Description of the calendar. + :param location: + Geographic location of the calendar as free-form text. + :param timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". By default, + the computers local timezone is used if it is configured. UTC is used otherwise. + :param allowed_conference_solution_types: + The types of conference solutions that are supported for this calendar. + See :py:class:`~gcsa.conference.SolutionType` + """ + self.summary = summary + self.calendar_id = calendar_id + self.description = description + self.location = location + self.timezone = timezone + self.allowed_conference_solution_types = allowed_conference_solution_types + + @property + def id(self): + return self.calendar_id + + def to_calendar_list_entry( + self, + summary_override: str = None, + color_id: str = None, + background_color: str = None, + foreground_color: str = None, + hidden: bool = False, + selected: bool = False, + default_reminders: List[Reminder] = None, + notification_types: List[str] = None, + ) -> 'CalendarListEntry': + """Converts :py:class:`~gcsa.calendar.Calendar` to :py:class:`~gcsa.calendar.CalendarListEntry` + that can be added to the calendar list. + + :py:class:`~gcsa.calendar.Calendar` has to have `calendar_id` set + to be converted to :py:class:`~gcsa.calendar.CalendarListEntry` + + :param summary_override: + The summary that the authenticated user has set for this calendar. + :param color_id: + The color of the calendar. This is an ID referring to an entry in the calendar section of the colors' + definition (See :py:meth:`~gcsa.google_calendar.GoogleCalendar.list_calendar_colors`). + This property is superseded by the `background_color` and `foreground_color` properties + and can be ignored when using these properties. + :param background_color: + The main color of the calendar in the hexadecimal format "#0088aa". + This property supersedes the index-based color_id property. + :param foreground_color: + The foreground color of the calendar in the hexadecimal format "#ffffff". + This property supersedes the index-based color_id property. + :param hidden: + Whether the calendar has been hidden from the list. + :param selected: + Whether the calendar content shows up in the calendar UI. The default is False. + :param default_reminders: + The default reminders that the authenticated user has for this calendar. :py:mod:`~gcsa.reminders` + :param notification_types: + The list of notification types set for this calendar. :py:class:`~gcsa:calendar:NotificationType` + + :return: + :py:class:`~gcsa.calendar.CalendarListEntry` object that can be added to the calendar list. + """ + if self.id is None: + raise ValueError('Calendar has to have `calendar_id` set to be converted to CalendarListEntry') + + return CalendarListEntry( + _summary=self.summary, + calendar_id=self.calendar_id, + _description=self.description, + _location=self.location, + _timezone=self.timezone, + _allowed_conference_solution_types=self.allowed_conference_solution_types, + + summary_override=summary_override, + color_id=color_id, + background_color=background_color, + foreground_color=foreground_color, + hidden=hidden, + selected=selected, + default_reminders=default_reminders, + notification_types=notification_types, + ) + + def __str__(self): + return '{} - {}'.format(self.summary, self.description) + + def __repr__(self): + return ''.format(self.__str__()) + + def __eq__(self, other): + if not isinstance(other, Calendar): + return NotImplemented + elif self is other: + return True + else: + return super().__eq__(other) + + +class CalendarListEntry(Calendar): + def __init__( + self, + calendar_id: str, + *, + summary_override: str = None, + color_id: str = None, + background_color: str = None, + foreground_color: str = None, + hidden: bool = False, + selected: bool = False, + default_reminders: List[Reminder] = None, + notification_types: List[str] = None, + _summary: str = None, + _description: str = None, + _location: str = None, + _timezone: str = None, + _allowed_conference_solution_types: List[str] = None, + _access_role: str = None, + _primary: bool = False, + _deleted: bool = False + ): + """ + :param calendar_id: + Identifier of the calendar. + :param summary_override: + The summary that the authenticated user has set for this calendar. + :param color_id: + The color of the calendar. This is an ID referring to an entry in the calendar section of the colors' + definition (See :py:meth:`~gcsa.google_calendar.GoogleCalendar.list_calendar_colors`). + This property is superseded by the `background_color` and `foreground_color` properties + and can be ignored when using these properties. + :param background_color: + The main color of the calendar in the hexadecimal format "#0088aa". + This property supersedes the index-based color_id property. + :param foreground_color: + The foreground color of the calendar in the hexadecimal format "#ffffff". + This property supersedes the index-based color_id property. + :param hidden: + Whether the calendar has been hidden from the list. + :param selected: + Whether the calendar content shows up in the calendar UI. The default is False. + :param default_reminders: + The default reminders that the authenticated user has for this calendar. :py:mod:`~gcsa.reminders` + :param notification_types: + The list of notification types set for this calendar. :py:class:`~gcsa:calendar:NotificationType` + :param _summary: + Title of the calendar. Read-only. + :param _description: + Description of the calendar. Read-only. + :param _location: + Geographic location of the calendar as free-form text. Read-only. + :param _timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". Read-only. + :param _allowed_conference_solution_types: + The types of conference solutions that are supported for this calendar. Read-only. + See :py:class:`~gcsa.conference.SolutionType` + :param _access_role: + The effective access role that the authenticated user has on the calendar. Read-only. + See :py:class:`~gcsa.calendar.AccessRoles` + :param _primary: + Whether the calendar is the primary calendar of the authenticated user. Read-only. + :param _deleted: + Whether this calendar list entry has been deleted from the calendar list. Read-only. + """ + super().__init__( + summary=_summary, + calendar_id=calendar_id, + description=_description, + location=_location, + timezone=_timezone, + allowed_conference_solution_types=_allowed_conference_solution_types + ) + self.summary_override = summary_override + self._color_id = color_id + self.background_color = background_color + self.foreground_color = foreground_color + self.hidden = hidden + self.selected = selected + self.default_reminders = default_reminders + self.notification_types = notification_types + self.access_role = _access_role + self.primary = _primary + self.deleted = _deleted + + @property + def color_id(self): + return self._color_id + + @color_id.setter + def color_id(self, color_id): + """Sets the color_id and resets background_color and foreground_color.""" + self._color_id = color_id + self.background_color = None + self.foreground_color = None + + def __str__(self): + return '{} - ({})'.format(self.summary_override, self.summary) + + def __repr__(self): + return ''.format(self.__str__()) + + def __eq__(self, other): + if not isinstance(other, CalendarListEntry): + return NotImplemented + elif self is other: + return True + else: + return super().__eq__(other) diff --git a/google-calendar-simple-api/gcsa/conference.py b/google-calendar-simple-api/gcsa/conference.py new file mode 100644 index 0000000000000000000000000000000000000000..1af1d098ba47c64e36210533686f19435b4e0826 --- /dev/null +++ b/google-calendar-simple-api/gcsa/conference.py @@ -0,0 +1,416 @@ +from typing import Union, List +from uuid import uuid4 + + +class SolutionType: + """ + * HANGOUT - for Hangouts for consumers (hangouts.google.com) + * NAMED_HANGOUT - for classic Hangouts for Google Workspace users (hangouts.google.com) + * HANGOUTS_MEET - for Google Meet (meet.google.com) + * ADD_ON - for 3P conference providers + """ + + HANGOUT = 'eventHangout' + NAMED_HANGOUT = 'eventNamedHangout' + HANGOUTS_MEET = 'hangoutsMeet' + ADD_ON = 'addOn' + + +class _BaseConferenceSolution: + """General conference-related information.""" + + def __init__( + self, + conference_id: str = None, + signature: str = None, + notes: str = None, + _status: str = 'success' + ): + """ + :param conference_id: + The ID of the conference. Optional. + Can be used by developers to keep track of conferences, should not be displayed to users. + + Values for solution types (see :py:class:`~gcsa.conference.SolutionType`): + + * HANGOUT: unset + * NAMED_HANGOUT: the name of the Hangout + * HANGOUTS_MEET: the 10-letter meeting code, for example "aaa-bbbb-ccc" + * ADD_ON: defined by 3P conference provider + + :param signature: + The signature of the conference data. + Generated on server side. Must be preserved while copying the conference data between events, + otherwise the conference data will not be copied. + None for a conference with a failed create request. + Optional for a conference with a pending create request. + :param notes: + String of additional notes (such as instructions from the domain administrator, legal notices) + to display to the user. Can contain HTML. The maximum length is 2048 characters + + :param _status: + The current status of the conference create request. Should not be set by developer. + + The possible values are: + + * "pending": the conference create request is still being processed. + * "failure": the conference create request failed, there are no entry points. + * "success": the conference create request succeeded, the entry points are populated. + In this case `ConferenceSolution` with created entry points + is stored in the event's `conference_data`. And `ConferenceSolutionCreateRequest` is omitted. + + Create requests are asynchronous. Check ``status`` field of event's ``conference_solution`` to find it's + status. If the status is ``"success"``, ``conference_solution`` will contain a + :py:class:`~gcsa.conference.ConferenceSolution` object and you'll be able to access it's field (like + ``entry_points``). Otherwise (if ``status`` is ``""pending"`` or ``"failure"``), ``conference_solution`` + will contain a :py:class:`~gcsa.conference.ConferenceSolutionCreateRequest` object. + + """ + if notes and len(notes) > 2048: + raise ValueError('Maximum notes length is 2048 characters.') + + self.conference_id = conference_id + self.signature = signature + self.notes = notes + self.status = _status + + def __eq__(self, other): + if not isinstance(other, _BaseConferenceSolution): + return NotImplemented + elif self is other: + return True + else: + return ( + self.conference_id == other.conference_id + and self.signature == other.signature + and self.notes == other.notes + ) + + +class EntryPoint: + """Information about individual conference entry points, such as URLs or phone numbers.""" + + VIDEO = 'video' + PHONE = 'phone' + SIP = 'sip' + MORE = 'more' + + ENTRY_POINT_TYPES = (VIDEO, PHONE, SIP, MORE) + + def __init__( + self, + entry_point_type: str, + uri: str = None, + label: str = None, + pin: str = None, + access_code: str = None, + meeting_code: str = None, + passcode: str = None, + password: str = None + ): + """ + When creating new conference data, populate only the subset of `meeting_code`, `access_code`, `passcode`, + `password`, and `pin` fields that match the terminology that the conference provider uses. + + Only the populated fields should be displayed. + + :param entry_point_type: + The type of the conference entry point. + + Possible values are: + + * VIDEO - joining a conference over HTTP. + A conference can have zero or one `VIDEO` entry point. + * PHONE - joining a conference by dialing a phone number. + A conference can have zero or more `PHONE` entry points. + * SIP - joining a conference over SIP. + A conference can have zero or one `SIP` entry point. + * MORE - further conference joining instructions, for example additional phone numbers. + A conference can have zero or one `MORE` entry point. + A conference with only a `MORE` entry point is not a valid conference. + + :param uri: + The URI of the entry point. The maximum length is 1300 characters. + Format: + + * for `VIDEO`, http: or https: schema is required. + * for `PHONE`, tel: schema is required. + The URI should include the entire dial sequence (e.g., tel:+12345678900,,,123456789;1234). + * for `SIP`, sip: schema is required, e.g., sip:12345678@myprovider.com. + * for `MORE`, http: or https: schema is required. + + :param label: + The label for the URI. + Visible to end users. Not localized. The maximum length is 512 characters. + + Examples: + + * for `VIDEO`: meet.google.com/aaa-bbbb-ccc + * for `PHONE`: +1 123 268 2601 + * for `SIP`: 12345678@altostrat.com + * for `MORE`: should not be filled + + :param pin: + The PIN to access the conference. The maximum length is 128 characters. + :param access_code: + The access code to access the conference. The maximum length is 128 characters. Optional. + :param meeting_code: + The meeting code to access the conference. The maximum length is 128 characters. + :param passcode: + The passcode to access the conference. The maximum length is 128 characters. + :param password: + The password to access the conference. The maximum length is 128 characters. + """ + + if entry_point_type and entry_point_type not in self.ENTRY_POINT_TYPES: + raise ValueError('"entry_point" must be one of {}. {} was provided.'.format( + ', '.join(self.ENTRY_POINT_TYPES), + entry_point_type + )) + if label and len(label) > 512: + raise ValueError('Maximum label length is 512 characters.') + if pin and len(pin) > 128: + raise ValueError('Maximum pin length is 128 characters.') + if access_code and len(access_code) > 128: + raise ValueError('Maximum access_code length is 128 characters.') + if meeting_code and len(meeting_code) > 128: + raise ValueError('Maximum meeting_code length is 128 characters.') + if passcode and len(passcode) > 128: + raise ValueError('Maximum passcode length is 128 characters.') + if password and len(password) > 128: + raise ValueError('Maximum password length is 128 characters.') + + self.entry_point_type = entry_point_type + self.uri = uri + self.label = label + self.pin = pin + self.access_code = access_code + self.meeting_code = meeting_code + self.passcode = passcode + self.password = password + + def __eq__(self, other): + if not isinstance(other, EntryPoint): + return NotImplemented + elif self is other: + return True + else: + return ( + self.entry_point_type == other.entry_point_type + and self.uri == other.uri + and self.label == other.label + and self.pin == other.pin + and self.access_code == other.access_code + and self.meeting_code == other.meeting_code + and self.passcode == other.passcode + and self.password == other.password + ) + + def __str__(self): + return "{} - '{}'".format(self.entry_point_type, self.uri) + + def __repr__(self): + return ''.format(self.__str__()) + + +class ConferenceSolution(_BaseConferenceSolution): + """Information about the conference solution, such as Hangouts or Google Meet.""" + + def __init__( + self, + entry_points: Union[EntryPoint, List[EntryPoint]], + solution_type: str = None, + name: str = None, + icon_uri: str = None, + conference_id: str = None, + signature: str = None, + notes: str = None + ): + """ + :param entry_points: + :py:class:`~gcsa.conference.EntryPoint` or list of :py:class:`~gcsa.conference.EntryPoint` s. + Information about individual conference entry points, such as URLs or phone numbers. + All of them must belong to the same conference. + :param solution_type: + Solution type. See :py:class:`~gcsa.conference.SolutionType` + + The possible values are: + + * HANGOUT - for Hangouts for consumers (hangouts.google.com) + * NAMED_HANGOUT - for classic Hangouts for Google Workspace users (hangouts.google.com) + * HANGOUTS_MEET - for Google Meet (meet.google.com) + * ADD_ON - for 3P conference providers + + :param name: + The user-visible name of this solution. Not localized. + :param icon_uri: + The user-visible icon for this solution. + :param conference_id: + The ID of the conference. Optional. + Can be used by developers to keep track of conferences, should not be displayed to users. + + Values for solution types (see :py:class:`~gcsa.conference.SolutionType`): + + * HANGOUT: unset + * NAMED_HANGOUT: the name of the Hangout + * HANGOUTS_MEET: the 10-letter meeting code, for example "aaa-bbbb-ccc" + * ADD_ON: defined by 3P conference provider + + :param signature: + The signature of the conference data. + Generated on server side. Must be preserved while copying the conference data between events, + otherwise the conference data will not be copied. + None for a conference with a failed create request. + Optional for a conference with a pending create request. + :param notes: + String of additional notes (such as instructions from the domain administrator, legal notices) + to display to the user. Can contain HTML. The maximum length is 2048 characters + """ + super().__init__(conference_id=conference_id, signature=signature, notes=notes) + + self.entry_points = [entry_points] if isinstance(entry_points, EntryPoint) else entry_points + self._check_entry_points() + + self.solution_type = solution_type + self.name = name + self.icon_uri = icon_uri + + def _check_entry_points(self): + """ + Checks counts of entry points types. + + * A conference can have zero or one `VIDEO` entry point. + * A conference can have zero or more `PHONE` entry points. + * A conference can have zero or one `SIP` entry point. + * A conference can have zero or one `MORE` entry point. + A conference with only a `MORE` entry point is not a valid conference. + """ + if len(self.entry_points) == 0: + raise ValueError('At least one entry point has to be provided.') + + video_count = 0 + sip_count = 0 + more_count = 0 + for ep in self.entry_points: + if ep.entry_point_type == EntryPoint.VIDEO: + video_count += 1 + elif ep.entry_point_type == EntryPoint.SIP: + sip_count += 1 + elif ep.entry_point_type == EntryPoint.MORE: + more_count += 1 + + if video_count > 1: + raise ValueError('A conference can have zero or one `VIDEO` entry point.') + if sip_count > 1: + raise ValueError('A conference can have zero or one `SIP` entry point.') + if more_count > 1: + raise ValueError('A conference can have zero or one `MORE` entry point.') + if more_count == len(self.entry_points): + raise ValueError('A conference with only a `MORE` entry point is not a valid conference.') + + def __eq__(self, other): + if not isinstance(other, ConferenceSolution): + return NotImplemented + elif self is other: + return True + else: + return ( + super().__eq__(other) + and self.entry_points == other.entry_points + and self.solution_type == other.solution_type + and self.name == other.name + and self.icon_uri == other.icon_uri + ) + + def __str__(self): + return '{} - {}'.format(self.solution_type, self.entry_points) + + def __repr__(self): + return ''.format(self.__str__()) + + +class ConferenceSolutionCreateRequest(_BaseConferenceSolution): + """ + A request to generate a new conference and attach it to the event. + The data is generated asynchronously. To see whether the data is present check the status field. + """ + + def __init__( + self, + solution_type: str = None, + request_id: str = None, + _status: str = None, + conference_id: str = None, + signature: str = None, + notes: str = None + ): + """ + :param solution_type: + Solution type. See :py:class:`~gcsa.conference.SolutionType` + + The possible values are: + + * HANGOUT - for Hangouts for consumers (hangouts.google.com) + * NAMED_HANGOUT - for classic Hangouts for Google Workspace users (hangouts.google.com) + * HANGOUTS_MEET - for Google Meet (meet.google.com) + * ADD_ON - for 3P conference providers + + :param request_id: + The client-generated unique ID for this request. + By default it is generated as UUID. + If you specify request_id manually, they should be unique for every new CreateRequest, + otherwise request will be ignored. + + :param _status: + The current status of the conference create request. Should not be set by developer. + + The possible values are: + + * "pending": the conference create request is still being processed. + * "failure": the conference create request failed, there are no entry points. + * "success": the conference create request succeeded, the entry points are populated. + In this case `ConferenceSolution` with created entry points + is stored in the event's `conference_data`. And `ConferenceSolutionCreateRequest` is omitted. + :param conference_id: + The ID of the conference. Optional. + Can be used by developers to keep track of conferences, should not be displayed to users. + + Values for solution types (see :py:class:`~gcsa.conference.SolutionType`): + + * HANGOUT: unset + * NAMED_HANGOUT: the name of the Hangout + * HANGOUTS_MEET: the 10-letter meeting code, for example "aaa-bbbb-ccc" + * ADD_ON: defined by 3P conference provider + + :param signature: + The signature of the conference data. + Generated on server side. Must be preserved while copying the conference data between events, + otherwise the conference data will not be copied. + None for a conference with a failed create request. + Optional for a conference with a pending create request. + :param notes: + String of additional notes (such as instructions from the domain administrator, legal notices) + to display to the user. Can contain HTML. The maximum length is 2048 characters + """ + super().__init__(conference_id=conference_id, signature=signature, notes=notes, _status=_status) + self.request_id = request_id or uuid4().hex + self.solution_type = solution_type + + def __eq__(self, other): + if not isinstance(other, ConferenceSolutionCreateRequest): + return NotImplemented + elif self is other: + return True + else: + return ( + super().__eq__(other) + and self.request_id == other.request_id + and self.solution_type == other.solution_type + and self.status == other.status + ) + + def __str__(self): + return "{} - status:'{}'".format(self.solution_type, self.status) + + def __repr__(self): + return ''.format(self.__str__()) diff --git a/google-calendar-simple-api/gcsa/event.py b/google-calendar-simple-api/gcsa/event.py new file mode 100644 index 0000000000000000000000000000000000000000..89b6cb0694e638d934ec3d09fe74b02010ffd544 --- /dev/null +++ b/google-calendar-simple-api/gcsa/event.py @@ -0,0 +1,330 @@ +from functools import total_ordering +from typing import List, Union + +from beautiful_date import BeautifulDate +from tzlocal import get_localzone_name +from datetime import datetime, date, timedelta, time + +from ._resource import Resource +from .attachment import Attachment +from .attendee import Attendee +from .conference import ConferenceSolution, ConferenceSolutionCreateRequest +from .person import Person +from .reminders import PopupReminder, EmailReminder, Reminder +from .util.date_time_util import ensure_localisation + + +class Visibility: + """Possible values of the event visibility. + + * `DEFAULT` - Uses the default visibility for events on the calendar. This is the default value. + * `PUBLIC` - The event is public and event details are visible to all readers of the calendar. + * `PRIVATE` - The event is private and only event attendees may view event details. + """ + + DEFAULT = "default" + PUBLIC = "public" + PRIVATE = "private" + + +class Transparency: + """Possible values of the event transparency. + + * `OPAQUE` - Default value. The event does block time on the calendar. + This is equivalent to setting 'Show me as' to 'Busy' in the Calendar UI. + * `TRANSPARENT` - The event does not block time on the calendar. + This is equivalent to setting 'Show me as' to 'Available' in the Calendar UI. + """ + + OPAQUE = 'opaque' + TRANSPARENT = 'transparent' + + +@total_ordering +class Event(Resource): + def __init__( + self, + summary: str, + start: Union[date, datetime, BeautifulDate], + end: Union[date, datetime, BeautifulDate] = None, + *, + timezone: str = get_localzone_name(), + event_id: str = None, + description: str = None, + location: str = None, + recurrence: Union[str, List[str]] = None, + color_id: str = None, + visibility: str = Visibility.DEFAULT, + attendees: Union[Attendee, str, List[Attendee], List[str]] = None, + attachments: Union[Attachment, List[Attachment]] = None, + conference_solution: Union[ConferenceSolution, ConferenceSolutionCreateRequest] = None, + reminders: Union[Reminder, List[Reminder]] = None, + default_reminders: bool = False, + minutes_before_popup_reminder: int = None, + minutes_before_email_reminder: int = None, + guests_can_invite_others: bool = True, + guests_can_modify: bool = False, + guests_can_see_other_guests: bool = True, + transparency: str = None, + _creator: Person = None, + _organizer: Person = None, + _created: datetime = None, + _updated: datetime = None, + _recurring_event_id: str = None, + **other + ): + """ + :param summary: + Title of the event. + :param start: + Starting date/datetime. + :param end: + Ending date/datetime. If 'end' is not specified, event is considered as a 1-day or 1-hour event + if 'start' is date or datetime respectively. + :param timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". By default, + the computers local timezone is used if it is configured. UTC is used otherwise. + :param event_id: + Opaque identifier of the event. By default, it is generated by the server. You can specify id as a + 5-1024 long string of characters used in base32hex ([a-vA-V0-9]). The ID must be unique per + calendar. + :param description: + Description of the event. Can contain HTML. + :param location: + Geographic location of the event as free-form text. + :param recurrence: + RRULE/RDATE/EXRULE/EXDATE string or list of such strings. See :py:mod:`~gcsa.recurrence` + :param color_id: + Color id referring to an entry from colors endpoint. + See :py:meth:`~gcsa.google_calendar.GoogleCalendar.list_event_colors` + :param visibility: + Visibility of the event. Default is default visibility for events on the calendar. + See :py:class:`~gcsa.event.Visibility` + :param attendees: + Attendee or list of attendees. See :py:class:`~gcsa.attendee.Attendee`. + Each attendee may be given as email string or :py:class:`~gcsa.attendee.Attendee` object. + :param attachments: + Attachment or list of attachments. See :py:class:`~gcsa.attachment.Attachment` + :param conference_solution: + :py:class:`~gcsa.conference.ConferenceSolutionCreateRequest` object to create a new conference + or :py:class:`~gcsa.conference.ConferenceSolution` object for existing conference. + :param reminders: + Reminder or list of reminder objects. See :py:mod:`~gcsa.reminders` + :param default_reminders: + Whether the default reminders of the calendar apply to the event. + :param minutes_before_popup_reminder: + Minutes before popup reminder or None if reminder is not needed. + :param minutes_before_email_reminder: + Minutes before email reminder or None if reminder is not needed. + :param guests_can_invite_others: + Whether attendees other than the organizer can invite others to the event. + :param guests_can_modify: + Whether attendees other than the organizer can modify the event. + :param guests_can_see_other_guests: + Whether attendees other than the organizer can see who the event's attendees are. + :param transparency: + Whether the event blocks time on the calendar. See :py:class:`~gcsa.event.Transparency` + :param _creator: + The creator of the event. See :py:class:`~gcsa.person.Person` + :param _organizer: + The organizer of the event. See :py:class:`~gcsa.person.Person`. + If the organizer is also an attendee, this is indicated with a separate entry in attendees with + the organizer field set to True. + To change the organizer, use the move operation + see :py:meth:`~gcsa.google_calendar.GoogleCalendar.move_event` + :param _created: + Creation time of the event. Read-only. + :param _updated: + Last modification time of the event. Read-only. + :param _recurring_event_id: + For an instance of a recurring event, this is the id of the recurring event to which + this instance belongs. Read-only. + :param other: + Other fields that should be included in request json. Will be included as they are. + See more in https://developers.google.com/calendar/v3/reference/events + """ + + def ensure_list(obj): + return [] if obj is None else obj if isinstance(obj, list) else [obj] + + self.timezone = timezone + self.start = start + if end or start is None: + self.end = end + elif isinstance(start, datetime): + self.end = start + timedelta(hours=1) + elif isinstance(start, date): + self.end = start + timedelta(days=1) + + if isinstance(self.start, datetime) and isinstance(self.end, datetime): + self.start = ensure_localisation(self.start, timezone) + self.end = ensure_localisation(self.end, timezone) + elif isinstance(self.start, datetime) or isinstance(self.end, datetime): + raise TypeError('Start and end must either both be date or both be datetime.') + + def ensure_date(d): + """Converts d to date if it is of type BeautifulDate.""" + if isinstance(d, BeautifulDate): + return date(year=d.year, month=d.month, day=d.day) + else: + return d + + self.start = ensure_date(self.start) + self.end = ensure_date(self.end) + + self.created = _created + self.updated = _updated + + attendees = [self._ensure_attendee_from_email(a) for a in ensure_list(attendees)] + reminders = ensure_list(reminders) + + if len(reminders) > 5: + raise ValueError('The maximum number of override reminders is 5.') + + if default_reminders and reminders: + raise ValueError('Cannot specify both default reminders and overrides at the same time.') + + self.event_id = event_id + self.summary = summary + self.description = description + self.location = location + self.recurrence = ensure_list(recurrence) + self.color_id = color_id + self.visibility = visibility + self.attendees = attendees + self.attachments = ensure_list(attachments) + self.conference_solution = conference_solution + self.reminders = reminders + self.default_reminders = default_reminders + self.recurring_event_id = _recurring_event_id + self.guests_can_invite_others = guests_can_invite_others + self.guests_can_modify = guests_can_modify + self.guests_can_see_other_guests = guests_can_see_other_guests + self.transparency = transparency + self.creator = _creator + self.organizer = _organizer + + self.other = other + + if minutes_before_popup_reminder is not None: + self.add_popup_reminder(minutes_before_popup_reminder) + if minutes_before_email_reminder is not None: + self.add_email_reminder(minutes_before_email_reminder) + + @property + def id(self): + return self.event_id + + def add_attendee( + self, + attendee: Union[str, Attendee] + ): + """Adds attendee to an event. See :py:class:`~gcsa.attendee.Attendee`. + Attendee may be given as email string or :py:class:`~gcsa.attendee.Attendee` object.""" + self.attendees.append(self._ensure_attendee_from_email(attendee)) + + def add_attendees( + self, + attendees: List[Union[str, Attendee]] + ): + """Adds multiple attendees to an event. See :py:class:`~gcsa.attendee.Attendee`. + Each attendee may be given as email string or :py:class:`~gcsa.attendee.Attendee` object.""" + for a in attendees: + self.add_attendee(a) + + def add_attachment( + self, + file_url: str, + title: str = None, + mime_type: str = None + ): + """Adds attachment to an event. See :py:class:`~gcsa.attachment.Attachment`""" + self.attachments.append(Attachment(file_url=file_url, title=title, mime_type=mime_type)) + + def add_email_reminder( + self, + minutes_before_start: int = None, + days_before: int = None, + at: time = None + ): + """Adds email reminder to an event. See :py:class:`~gcsa.reminders.EmailReminder`""" + self.add_reminder(EmailReminder(minutes_before_start, days_before, at)) + + def add_popup_reminder( + self, + minutes_before_start: int = None, + days_before: int = None, + at: time = None + ): + """Adds popup reminder to an event. See :py:class:`~gcsa.reminders.PopupReminder`""" + self.add_reminder(PopupReminder(minutes_before_start, days_before, at)) + + def add_reminder( + self, + reminder: Reminder + ): + """Adds reminder to an event. See :py:mod:`~gcsa.reminders`""" + if len(self.reminders) > 4: + raise ValueError('The maximum number of override reminders is 5.') + self.reminders.append(reminder) + + @staticmethod + def _ensure_attendee_from_email( + attendee_or_email: Union[str, Attendee] + ): + """If attendee_or_email is email string, returns created :py:class:`~gcsa.attendee.Attendee` + object with the given email.""" + if isinstance(attendee_or_email, str): + return Attendee(email=attendee_or_email) + else: + return attendee_or_email + + @property + def is_recurring_instance(self): + return self.recurring_event_id is not None + + def __str__(self): + return '{} - {}'.format(self.start, self.summary) + + def __repr__(self): + return ''.format(self.__str__()) + + def __lt__(self, other): + def ensure_datetime(d, timezone): + if type(d) is date: + return ensure_localisation(datetime(year=d.year, month=d.month, day=d.day), timezone) + else: + return d + + start = ensure_datetime(self.start, self.timezone) + end = ensure_datetime(self.end, self.timezone) + + other_start = ensure_datetime(other.start, other.timezone) + other_end = ensure_datetime(other.end, other.timezone) + + return (start, end) < (other_start, other_end) + + def __eq__(self, other): + return ( + isinstance(other, Event) + and self.start == other.start + and self.end == other.end + and self.event_id == other.event_id + and self.summary == other.summary + and self.description == other.description + and self.location == other.location + and self.recurrence == other.recurrence + and self.color_id == other.color_id + and self.visibility == other.visibility + and self.attendees == other.attendees + and self.attachments == other.attachments + and self.reminders == other.reminders + and self.default_reminders == other.default_reminders + and self.created == other.created + and self.updated == other.updated + and self.recurring_event_id == other.recurring_event_id + and self.guests_can_invite_others == other.guests_can_invite_others + and self.guests_can_modify == other.guests_can_modify + and self.guests_can_see_other_guests == other.guests_can_see_other_guests + and self.other == other.other + ) diff --git a/google-calendar-simple-api/gcsa/free_busy.py b/google-calendar-simple-api/gcsa/free_busy.py new file mode 100644 index 0000000000000000000000000000000000000000..6da47bac97f98a73eacf7e187a6eec5b0f9106b2 --- /dev/null +++ b/google-calendar-simple-api/gcsa/free_busy.py @@ -0,0 +1,98 @@ +import json +from collections import namedtuple +from datetime import datetime +from typing import Dict, List + +TimeRange = namedtuple('TimeRange', ('start', 'end')) + + +class FreeBusy: + def __init__( + self, + *, + time_min: datetime, + time_max: datetime, + groups: Dict[str, List[str]], + calendars: Dict[str, List[TimeRange]], + groups_errors: Dict = None, + calendars_errors: Dict = None, + ): + """Represents free/busy information for a given calendar(s) and/or group(s) + + :param time_min: + The start of the interval. + :param time_max: + The end of the interval. + :param groups: + Expansion of groups. + Dictionary that maps the name of the group to the list of calendars that are members of this group. + :param calendars: + Free/busy information for calendars. + Dictionary that maps calendar id to the list of time ranges during which this calendar should be + regarded as busy. + :param groups_errors: + Optional error(s) (if computation for the group failed). + Dictionary that maps the name of the group to the list of errors. + :param calendars_errors: + Optional error(s) (if computation for the calendar failed). + Dictionary that maps calendar id to the list of errors. + + + .. note:: Errors have the following format: + + .. code-block:: + + { + "domain": "", + "reason": "" + } + + Some possible values for "reason" are: + + * "groupTooBig" - The group of users requested is too large for a single query. + * "tooManyCalendarsRequested" - The number of calendars requested is too large for a single query. + * "notFound" - The requested resource was not found. + * "internalError" - The API service has encountered an internal error. + + Additional error types may be added in the future. + """ + self.time_min = time_min + self.time_max = time_max + self.groups = groups + self.calendars = calendars + self.groups_errors = groups_errors or {} + self.calendars_errors = calendars_errors or {} + + def __iter__(self): + """ + :returns: + list of 'TimeRange's during which this calendar should be regarded as busy. + :raises: + ValueError if requested all requested calendars have errors + or more than one calendar has been requested. + """ + if len(self.calendars) == 0: + raise ValueError("No free/busy information has been received. " + "Check the 'calendars_errors' and 'groups_errors' fields.") + if len(self.calendars) > 1 or len(self.calendars_errors) > 0: + raise ValueError("Can't iterate over FreeBusy objects directly when more than one calendars were requested." + "Use 'calendars' field instead to get free/busy information of the specific calendar.") + return iter(next(iter(self.calendars.values()))) + + def __str__(self): + return ''.format(self.time_min, self.time_max) + + def __repr__(self): + return self.__str__() + + +class FreeBusyQueryError(Exception): + def __init__(self, groups_errors, calendars_errors): + message = '\n' + if groups_errors: + message += f'Groups errors: {json.dumps(groups_errors, indent=4)}' + if calendars_errors: + message += f'Calendars errors: {json.dumps(calendars_errors, indent=4)}' + super().__init__(message) + self.groups_errors = groups_errors + self.calendars_errors = calendars_errors diff --git a/google-calendar-simple-api/gcsa/google_calendar.py b/google-calendar-simple-api/gcsa/google_calendar.py new file mode 100644 index 0000000000000000000000000000000000000000..65e8a8d9d63e21e30ab7f7ded1167a2de3aa7bd5 --- /dev/null +++ b/google-calendar-simple-api/gcsa/google_calendar.py @@ -0,0 +1,81 @@ +from google.oauth2.credentials import Credentials + +from ._services.acl_service import ACLService +from ._services.events_service import EventsService, SendUpdatesMode # noqa: F401 +from ._services.calendars_service import CalendarsService +from ._services.calendar_lists_service import CalendarListService +from ._services.colors_service import ColorsService +from ._services.free_busy_service import FreeBusyService +from ._services.settings_service import SettingsService + + +class GoogleCalendar( + EventsService, + CalendarsService, + CalendarListService, + ColorsService, + SettingsService, + ACLService, + FreeBusyService +): + """Collection of all supported methods for events and calendars management.""" + + def __init__( + self, + default_calendar: str = 'primary', + *, + credentials: Credentials = None, + credentials_path: str = None, + token_path: str = None, + save_token: bool = True, + read_only: bool = False, + authentication_flow_host: str = 'localhost', + authentication_flow_port: int = 8080, + authentication_flow_bind_addr: str = None + ): + """ + Specify ``credentials`` to use in requests or ``credentials_path`` and ``token_path`` to get credentials from. + + :param default_calendar: + Users email address or name/id of the calendar. Default: primary calendar of the user + + If user's email or "primary" is specified, then primary calendar of the user is used. + You don't need to specify this parameter in this case as it is a default behaviour. + + To use a different calendar you need to specify its id. + Go to calendar's `settings and sharing` -> `Integrate calendar` -> `Calendar ID`. + :param credentials: + Credentials with token and refresh token. + If specified, ``credentials_path``, ``token_path``, and ``save_token`` are ignored. + If not specified, credentials are retrieved from "token.pickle" file (specified in ``token_path`` or + default path) or with authentication flow using secret from "credentials.json" ("client_secret_*.json") + (specified in ``credentials_path`` or default path) + :param credentials_path: + Path to "credentials.json" ("client_secret_*.json") file. + Default: ~/.credentials/credentials.json or ~/.credentials/client_secret*.json + :param token_path: + Existing path to load the token from, or path to save the token after initial authentication flow. + Default: "token.pickle" in the same directory as the credentials_path + :param save_token: + Whether to pickle token after authentication flow for future uses + :param read_only: + If require read only access. Default: False + :param authentication_flow_host: + Host to receive response during authentication flow + :param authentication_flow_port: + Port to receive response during authentication flow + :param authentication_flow_bind_addr: + Optional IP address for the redirect server to listen on when it is not the same as host + (e.g. in a container) + """ + super().__init__( + default_calendar=default_calendar, + credentials=credentials, + credentials_path=credentials_path, + token_path=token_path, + save_token=save_token, + read_only=read_only, + authentication_flow_host=authentication_flow_host, + authentication_flow_port=authentication_flow_port, + authentication_flow_bind_addr=authentication_flow_bind_addr + ) diff --git a/google-calendar-simple-api/gcsa/person.py b/google-calendar-simple-api/gcsa/person.py new file mode 100644 index 0000000000000000000000000000000000000000..e663c0724a3c31073339053c066b5efcc00f1268 --- /dev/null +++ b/google-calendar-simple-api/gcsa/person.py @@ -0,0 +1,41 @@ +class Person: + def __init__( + self, + email: str = None, + display_name: str = None, + _id: str = None, + _is_self: bool = None + ): + """Represents organizer's, creator's, or primary attendee's fields. + For attendees see more in :py:class:`~gcsa.attendee.Attendee`. + + :param email: + The person's email address, if available + :param display_name: + The person's name, if available + :param _id: + The person's Profile ID, if available. + It corresponds to the id field in the People collection of the Google+ API + :param _is_self: + Whether the person corresponds to the calendar on which the copy of the event appears. + The default is False (set by Google's API). + """ + self.email = email + self.display_name = display_name + self.id_ = _id + self.is_self = _is_self + + def __eq__(self, other): + return ( + isinstance(other, Person) + and self.email == other.email + and self.display_name == other.display_name + and self.id_ == other.id_ + and self.is_self == other.is_self + ) + + def __str__(self): + return "'{}' - '{}'".format(self.email, self.display_name) + + def __repr__(self): + return ''.format(self.__str__()) diff --git a/google-calendar-simple-api/gcsa/recurrence.py b/google-calendar-simple-api/gcsa/recurrence.py new file mode 100644 index 0000000000000000000000000000000000000000..3721effb04c212a391559fa482aee81e0fed3b47 --- /dev/null +++ b/google-calendar-simple-api/gcsa/recurrence.py @@ -0,0 +1,570 @@ +from datetime import datetime, date + +from tzlocal import get_localzone_name + +from .util.date_time_util import ensure_localisation + + +class Duration: + """Represents properties that contain a duration of time.""" + + def __init__(self, w=None, d=None, h=None, m=None, s=None): + """ + :param w: weeks + :param d: days + :param h: hours + :param m: minutes + :param s: seconds + """ + + self.w = w + self.d = d + self.h = h + self.m = m + self.s = s + + def __str__(self): + res = 'P' + if self.w: + res += '{}W'.format(self.w) + if self.d: + res += '{}D'.format(self.d) + if self.h or self.m or self.s: + res += 'T' + if self.h: + res += '{}H'.format(self.h) + if self.m: + res += '{}M'.format(self.m) + if self.s: + res += '{}S'.format(self.s) + + return res + + +class _DayOfTheWeek: + """Weekday representation. Optionally includes positive or negative integer + value that indicates the nth occurrence of a specific day within the "MONTHLY" + or "YEARLY" recurrence rules. + + >>> str(SU) + 'SU' + + >>> str(FR) + 'FR' + + >>> str(SU(4)) + '4SU' + + >>> str(SU(-1)) + '-1SU' + """ + + def __init__(self, short, n=None): + self.short = short + self.n = n + + def __call__(self, n): + return _DayOfTheWeek(self.short, n) + + def __str__(self): + if self.n is None: + return self.short + else: + return str(self.n) + self.short + + +SU = SUNDAY = _DayOfTheWeek('SU') +MO = MONDAY = _DayOfTheWeek('MO') +TU = TUESDAY = _DayOfTheWeek('TU') +WE = WEDNESDAY = _DayOfTheWeek('WE') +TH = THURSDAY = _DayOfTheWeek('TH') +FR = FRIDAY = _DayOfTheWeek('FR') +SA = SATURDAY = _DayOfTheWeek('SA') + +DEFAULT_WEEK_START = SUNDAY + +SECONDLY = 'SECONDLY' +MINUTELY = 'MINUTELY' +HOURLY = 'HOURLY' + +DAILY = 'DAILY' +WEEKLY = 'WEEKLY' +MONTHLY = 'MONTHLY' +YEARLY = 'YEARLY' + + +class Recurrence: + + @staticmethod + def rule( + freq=DAILY, + interval=None, + count=None, + until=None, + by_second=None, + by_minute=None, + by_hour=None, + by_week_day=None, + by_month_day=None, + by_year_day=None, + by_week=None, + by_month=None, + by_set_pos=None, + week_start=DEFAULT_WEEK_START + ): + """This property defines a rule or repeating pattern for recurring events. + + :param freq: + Identifies the type of recurrence rule. Possible values are SECONDLY, HOURLY, + MINUTELY, DAILY, WEEKLY, MONTHLY, YEARLY. Default: DAILY + :param interval: + Positive integer representing how often the recurrence rule repeats + :param count: + Number of occurrences at which to range-bound the recurrence + :param until: + End date of recurrence + :param by_second: + Second or list of seconds within a minute. Valid values are 0 to 60 + :param by_minute: + Minute or list of minutes within a hour. Valid values are 0 to 59 + :param by_hour: + Hour or list of hours of the day. Valid values are 0 to 23 + :param by_week_day: + Day or list of days of the week. + Possible values: :py:obj:`~SUNDAY`, :py:obj:`~MONDAY`, :py:obj:`~TUESDAY`, :py:obj:`~WEDNESDAY`, + :py:obj:`~THURSDAY`, :py:obj:`~FRIDAY`, :py:obj:`~SATURDAY` + :param by_month_day: + Day or list of days of the month. Valid values are 1 to 31 or -31 to -1. + For example, -10 represents the tenth to the last day of the month. + :param by_year_day: + Day or list of days of the year. Valid values are 1 to 366 or -366 to -1. + For example, -1 represents the last day of the year. + :param by_week: + Ordinal or list of ordinals specifying weeks of the year. Valid values are 1 to 53 or -53 to -1. + :param by_month: + Month or list of months of the year. Valid values are 1 to 12. + :param by_set_pos: + Value or list of values which corresponds to the nth occurrence within the set of events + specified by the rule. Valid values are 1 to 366 or -366 to -1. + It can only be used in conjunction with another by_xxx parameter. + :param week_start: + The day on which the workweek starts. + Possible values: :py:obj:`~SUNDAY`, :py:obj:`~MONDAY`, :py:obj:`~TUESDAY`, :py:obj:`~WEDNESDAY`, + :py:obj:`~THURSDAY`, :py:obj:`~FRIDAY`, :py:obj:`~SATURDAY` + + :return: + String representing specified recurrence rule in `RRULE format`_. + + .. note:: If none of the by_day, by_month_day, or by_year_day are specified, the day is gotten from start date. + + + .. _`RRULE format`: https://tools.ietf.org/html/rfc5545#section-3.8.5 + """ + return 'RRULE:' + Recurrence._rule(freq, interval, count, until, by_second, by_minute, by_hour, by_week_day, + by_month_day, by_year_day, by_week, by_month, by_set_pos, week_start) + + @staticmethod + def exclude_rule( + freq=DAILY, + interval=None, + count=None, + until=None, + by_second=None, + by_minute=None, + by_hour=None, + by_week_day=None, + by_month_day=None, + by_year_day=None, + by_week=None, + by_month=None, + by_set_pos=None, + week_start=DEFAULT_WEEK_START + ): + """This property defines an exclusion rule or repeating pattern for recurring events. + + :param freq: + Identifies the type of recurrence rule. Possible values are SECONDLY, HOURLY, + MINUTELY, DAILY, WEEKLY, MONTHLY, YEARLY. Default: DAILY + :param interval: + Positive integer representing how often the recurrence rule repeats + :param count: + Number of occurrences at which to range-bound the recurrence + :param until: + End date of recurrence + :param by_second: + Second or list of seconds within a minute. Valid values are 0 to 60 + :param by_minute: + Minute or list of minutes within a hour. Valid values are 0 to 59 + :param by_hour: + Hour or list of hours of the day. Valid values are 0 to 23 + :param by_week_day: + Day or list of days of the week. + Possible values: :py:obj:`~SUNDAY`, :py:obj:`~MONDAY`, :py:obj:`~TUESDAY`, :py:obj:`~WEDNESDAY`, + :py:obj:`~THURSDAY`, :py:obj:`~FRIDAY`, :py:obj:`~SATURDAY` + :param by_month_day: + Day or list of days of the month. Valid values are 1 to 31 or -31 to -1. + For example, -10 represents the tenth to the last day of the month. + :param by_year_day: + Day or list of days of the year. Valid values are 1 to 366 or -366 to -1. + For example, -1 represents the last day of the year. + :param by_week: + Ordinal or list of ordinals specifying weeks of the year. Valid values are 1 to 53 or -53 to -1. + :param by_month: + Month or list of months of the year. Valid values are 1 to 12. + :param by_set_pos: + Value or list of values which corresponds to the nth occurrence within the set of events + specified by the rule. Valid values are 1 to 366 or -366 to -1. + It can only be used in conjunction with another by_xxx parameter. + :param week_start: + The day on which the workweek starts. + Possible values: :py:obj:`~SUNDAY`, :py:obj:`~MONDAY`, :py:obj:`~TUESDAY`, :py:obj:`~WEDNESDAY`, + :py:obj:`~THURSDAY`, :py:obj:`~FRIDAY`, :py:obj:`~SATURDAY` + + :return: + String representing specified recurrence rule in `RRULE format`_. + + .. note:: If none of the by_day, by_month_day, or by_year_day are specified, the day is gotten from start date. + + + .. _`RRULE format`: https://tools.ietf.org/html/rfc5545#section-3.8.5 + """ + return 'EXRULE:' + Recurrence._rule(freq, interval, count, until, by_second, by_minute, by_hour, by_week_day, + by_month_day, by_year_day, by_week, by_month, by_set_pos, week_start) + + @staticmethod + def dates(ds): + """Converts date(s) set to RDATE format. + + :param ds: + date/datetime object or list of date/datetime objects + + :return: + RDATE string of dates. + """ + return 'RDATE;' + Recurrence._dates(ds) + + @staticmethod + def times(dts, timezone=get_localzone_name()): + """Converts datetime(s) set to RDATE format. + + :param dts: + datetime object or list of datetime objects + :param timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". By default, + the computers local timezone is used if it is configured. UTC is used otherwise. + + :return: + RDATE string of datetimes with specified timezone. + """ + return 'RDATE;' + Recurrence._times(dts, timezone) + + @staticmethod + def periods(ps, timezone=get_localzone_name()): + """Converts date period(s) to RDATE format. + + Period is defined as tuple of starting date/datetime and ending date/datetime or duration as Duration object: + (date/datetime, date/datetime/Duration) + + :param ps: + Period or list of periods. + :param timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". By default, + the computers local timezone is used if it is configured. UTC is used otherwise. + + :return: + RDATE string of periods. + """ + return 'RDATE;' + Recurrence._periods(ps, timezone) + + @staticmethod + def exclude_dates(ds): + """Converts date(s) set to EXDATE format. + + :param ds: + date/datetime object or list of date/datetime objects + + :return: + EXDATE string of dates. + """ + return 'EXDATE;' + Recurrence._dates(ds) + + @staticmethod + def exclude_times(dts, timezone=get_localzone_name()): + """Converts datetime(s) set to EXDATE format. + + :param dts: + datetime object or list of datetime objects + :param timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". By default, + the computers local timezone is used if it is configured. UTC is used otherwise. + + :return: + EXDATE string of datetimes with specified timezone. + """ + return 'EXDATE;' + Recurrence._times(dts, timezone) + + @staticmethod + def exclude_periods(ps, timezone=get_localzone_name()): + """Converts date period(s) to EXDATE format. + + Period is defined as tuple of starting date/datetime and ending date/datetime or duration as Duration object: + (date/datetime, date/datetime/Duration) + + :param ps: + Period or list of periods. + :param timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". By default, + the computers local timezone is used if it is configured. UTC is used otherwise. + + :return: + EXDATE string of periods. + """ + return 'EXDATE;' + Recurrence._periods(ps, timezone) + + @staticmethod + def _times(dts, timezone=get_localzone_name()): + """Converts datetime(s) set to RDATE format. + + :param dts: + datetime object or list of datetime objects + :param timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". By default, + the computers local timezone is used if it is configured. UTC is used otherwise. + + :return: + RDATE string of datetimes with specified timezone. + """ + + if not isinstance(dts, list): + dts = [dts] + + localized_datetimes = [] + for dt in dts: + if not isinstance(dt, (date, datetime)): + msg = 'The dts object(s) must be date or datetime, not {!r}.'.format(dt.__class__.__name__) + raise TypeError(msg) + localized_datetimes.append(ensure_localisation(dt, timezone)) + + return 'TZID={}:{}'.format(timezone, ','.join(d.strftime('%Y%m%dT%H%M%S') for d in localized_datetimes)) + + @staticmethod + def _dates(ds): + """Converts date(s) set to RDATE format. + + :param ds: + date/datetime object or list of date/datetime objects + + :return: + RDATE string of dates. + """ + if not isinstance(ds, list): + ds = [ds] + + for d in ds: + if not (isinstance(d, (date, datetime))): + msg = 'The dates object(s) must be date or datetime, not {!r}.'.format(d.__class__.__name__) + raise TypeError(msg) + + return 'VALUE=DATE:' + ','.join(d.strftime('%Y%m%d') for d in ds) + + @staticmethod + def _periods(ps, timezone=get_localzone_name()): + """Converts date period(s) to RDATE format. + + Period is defined as tuple of starting date/datetime and ending date/datetime or duration as Duration object: + (date/datetime, date/datetime/Duration) + + :param ps: + Period or list of periods. + :param timezone: + Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". By default, + the computers local timezone is used if it is configured. UTC is used otherwise. + + :return: + RDATE string of periods. + """ + if not isinstance(ps, list): + ps = [ps] + + period_strings = [] + for start, end in ps: + if not isinstance(start, (date, datetime)): + msg = 'The start object(s) must be a date or datetime, not {!r}.'.format(end.__class__.__name__) + raise TypeError(msg) + + start = ensure_localisation(start, timezone) + if isinstance(end, (date, datetime)): + end = ensure_localisation(end, timezone) + pstr = '{}/{}'.format(start.strftime('%Y%m%dT%H%M%SZ'), end.strftime('%Y%m%dT%H%M%SZ')) + elif isinstance(end, Duration): + pstr = '{}/{}'.format(start.strftime('%Y%m%dT%H%M%SZ'), end) + else: + msg = 'The end object(s) must be a date, datetime or Duration, not {!r}.'.format(end.__class__.__name__) + raise TypeError(msg) + period_strings.append(pstr) + + return 'VALUE=PERIOD:' + ','.join(period_strings) + + @staticmethod + def _rule( + freq=DAILY, + interval=None, + count=None, + until=None, + by_second=None, # BYSECOND + by_minute=None, # BYMINUTE + by_hour=None, # BYHOUR + by_week_day=None, # BYDAY + by_month_day=None, # BYMONTHDAY + by_year_day=None, # BYYEARDAY + by_week=None, # BYWEEKNO + by_month=None, # BYMONTH + by_set_pos=None, # BYSETPOS + week_start=DEFAULT_WEEK_START # WKST + ): + """This property defines a rule or repeating pattern for recurring events. + + :param freq: + Identifies the type of recurrence rule. Possible values are SECONDLY, HOURLY, + MINUTELY, DAILY, WEEKLY, MONTHLY, YEARLY. Default: DAILY + :param interval: + Positive integer representing how often the recurrence rule repeats + :param count: + Number of occurrences at which to range-bound the recurrence + :param until: + End date of recurrence + :param by_second: + Second or list of seconds within a minute. Valid values are 0 to 60 + :param by_minute: + Minute or list of minutes within a hour. Valid values are 0 to 59 + :param by_hour: + Hour or list of hours of the day. Valid values are 0 to 23 + :param by_week_day: + Day or list of days of the week. + Possible values: :py:obj:`~SUNDAY`, :py:obj:`~MONDAY`, :py:obj:`~TUESDAY`, :py:obj:`~WEDNESDAY`, + :py:obj:`~THURSDAY`, :py:obj:`~FRIDAY`, :py:obj:`~SATURDAY` + :param by_month_day: + Day or list of days of the month. Valid values are 1 to 31 or -31 to -1. + For example, -10 represents the tenth to the last day of the month. + :param by_year_day: + Day or list of days of the year. Valid values are 1 to 366 or -366 to -1. + For example, -1 represents the last day of the year. + :param by_week: + Ordinal or list of ordinals specifying weeks of the year. Valid values are 1 to 53 or -53 to -1. + :param by_month: + Month or list of months of the year. Valid values are 1 to 12. + :param by_set_pos: + Value or list of values which corresponds to the nth occurrence within the set of events + specified by the rule. Valid values are 1 to 366 or -366 to -1. + It can only be used in conjunction with another by_xxx parameter. + :param week_start: + The day on which the workweek starts. + Possible values: :py:obj:`~SUNDAY`, :py:obj:`~MONDAY`, :py:obj:`~TUESDAY`, :py:obj:`~WEDNESDAY`, + :py:obj:`~THURSDAY`, :py:obj:`~FRIDAY`, :py:obj:`~SATURDAY` + + :return: + String representing specified recurrence rule in `RRULE format`_. + + .. note:: If none of the by_day, by_month_day, or by_year_day are specified, the day is gotten from start date. + + + .. _`RRULE format`: https://tools.ietf.org/html/rfc5545#section-3.8.5 + """ + + def ensure_iterable(it): + return it if isinstance(it, (list, tuple, set)) else [it] if it is not None else [] + + def check_all_type(it, type_, name): + if any(not isinstance(o, type_) for o in it): + raise TypeError('"{}" parameter must be a {} or list of {}s.' + .format(name, type_.__name__, type_.__name__)) + + def check_all_type_and_range(it, type_, range_, name, nonzero=False): + check_all_type(it, type_, name) + low, high = range_ + if any(not (low <= o <= high) for o in it): + raise ValueError('"{}" parameter must be in range {}-{}.' + .format(name, low, high)) + if nonzero and any(o == 0 for o in it): + raise ValueError('"{}" parameter must be in range {}-{} and nonzero.' + .format(name, low, high)) + + def to_string(values): + return ','.join(map(str, values)) if values else None + + if freq not in (SECONDLY, MINUTELY, HOURLY, DAILY, WEEKLY, MONTHLY, YEARLY): + raise ValueError('"freq" parameter must be one of SECONDLY, HOURLY, MINUTELY, DAILY, ' + 'WEEKLY, MONTHLY or YEARLY. {} was provided'.format(freq)) + if interval is not None and (not isinstance(interval, int) or interval < 1): + raise ValueError('"interval" parameter must be a positive int. ' + '{} was provided'.format(interval)) + if count is not None and (not isinstance(count, int) or count < 1): + raise ValueError('"count" parameter must be a positive int. ' + '{} was provided'.format(count)) + if until is not None: + if not isinstance(until, (date, datetime)): + raise TypeError('The until object must be a date or datetime, ' + 'not {!r}.'.format(until.__class__.__name__)) + else: + until = until.strftime("%Y%m%dT%H%M%SZ") + if count is not None and until is not None: + raise ValueError('"count" and "until" may not appear in one recurrence rule.') + + by_second = ensure_iterable(by_second) + check_all_type_and_range(by_second, int, (0, 60), "by_second") + + by_minute = ensure_iterable(by_minute) + check_all_type_and_range(by_minute, int, (0, 59), "by_minute") + + by_hour = ensure_iterable(by_hour) + check_all_type_and_range(by_hour, int, (0, 23), "by_hour") + + by_week_day = ensure_iterable(by_week_day) + check_all_type(by_week_day, _DayOfTheWeek, "by_week_day") + + by_month_day = ensure_iterable(by_month_day) + check_all_type_and_range(by_month_day, int, (-31, 31), "by_month_day", nonzero=True) + + by_year_day = ensure_iterable(by_year_day) + check_all_type_and_range(by_year_day, int, (-366, 366), "by_year_day", nonzero=True) + + by_week = ensure_iterable(by_week) + check_all_type_and_range(by_week, int, (-53, 53), "by_week", nonzero=True) + + by_month = ensure_iterable(by_month) + check_all_type_and_range(by_month, int, (1, 12), "by_month") + + by_set_pos = ensure_iterable(by_set_pos) + check_all_type_and_range(by_set_pos, int, (-366, 366), "by_set_pos", nonzero=True) + if by_set_pos and all(not v for v in (by_second, by_minute, by_hour, + by_week_day, by_month_day, by_year_day, + by_week, by_month)): + raise ValueError('"by_set_pos" parameter can only be used in conjunction with another by_xxx parameter.') + + if not isinstance(week_start, _DayOfTheWeek): + raise ValueError('"week_start" parameter must be one of SUNDAY, MONDAY, etc. ' + '{} was provided'.format(week_start)) + + rrule = 'FREQ={}'.format(freq) + + rule_properties = ( + ('INTERVAL', interval), + ('COUNT', count), + ('UNTIL', until), + ('BYSECOND', to_string(by_second)), + ('BYMINUTE', to_string(by_minute)), + ('BYHOUR', to_string(by_hour)), + ('BYDAY', to_string(by_week_day)), + ('BYMONTHDAY', to_string(by_month_day)), + ('BYYEARDAY', to_string(by_year_day)), + ('BYWEEKNO', to_string(by_week)), + ('BYMONTH', to_string(by_month)), + ('BYSETPOS', to_string(by_set_pos)), + ('WKST', week_start) + ) + + for key, value in rule_properties: + if value: + rrule += ';{}={}'.format(key, value) + + return rrule diff --git a/google-calendar-simple-api/gcsa/reminders.py b/google-calendar-simple-api/gcsa/reminders.py new file mode 100644 index 0000000000000000000000000000000000000000..9e39321bc308ae6c1b791905cd04a0d3c44ceaa0 --- /dev/null +++ b/google-calendar-simple-api/gcsa/reminders.py @@ -0,0 +1,137 @@ +from datetime import time, date, datetime +from typing import Union + +from beautiful_date import BeautifulDate, days + + +class Reminder: + def __init__( + self, + method: str, + minutes_before_start: int = None, + days_before: int = None, + at: time = None + ): + """Represents base reminder object + + Provide `minutes_before_start` to create "relative" reminder. + Provide `days_before` and `at` to create "absolute" reminder. + + :param method: + Method of the reminder. Possible values: email or popup + :param minutes_before_start: + Minutes before reminder + :param days_before: + Days before reminder + :param at: + Specific time for a reminder + """ + # Nothing was provided + if minutes_before_start is None and days_before is None and at is None: + raise ValueError("Relative reminder needs 'minutes_before_start'. " + "Absolute reminder 'days_before' and 'at' set. " + "None of them were provided.") + + # Both minutes_before_start and days_before/at were provided + if minutes_before_start is not None and (days_before is not None or at is not None): + raise ValueError("Only minutes_before_start or days_before/at can be specified.") + + # Only one of days_before and at was provided + if (days_before is None) != (at is None): + raise ValueError(f'Both "days_before" and "at" values need to be set ' + f'when using absolute time for a reminder. ' + f'Provided days_before={days_before} and at={at}.') + + self.method = method + self.minutes_before_start = minutes_before_start + self.days_before = days_before + self.at = at + + def __eq__(self, other): + return ( + isinstance(other, Reminder) + and self.method == other.method + and self.minutes_before_start == other.minutes_before_start + and self.days_before == other.days_before + and self.at == other.at + ) + + def __str__(self): + if self.minutes_before_start is not None: + return '{} - minutes_before_start:{}'.format(self.__class__.__name__, self.minutes_before_start) + else: + return '{} - {} days before at {}'.format(self.__class__.__name__, self.days_before, self.at) + + def __repr__(self): + return '<{}>'.format(self.__str__()) + + def convert_to_relative(self, start: Union[date, datetime, BeautifulDate]) -> 'Reminder': + """Converts absolute reminder (with set `days_before` and `at`) to relative (with set `minutes_before_start`) + relative to `start` date/datetime. Returns self if `minutes_before_start` already set. + """ + if self.minutes_before_start is not None: + return self + + tzinfo = start.tzinfo if isinstance(start, datetime) else None + start_of_the_day = datetime.combine(start, datetime.min.time(), tzinfo=tzinfo) + + reminder_tzinfo = self.at.tzinfo or tzinfo + reminder_time = datetime.combine(start_of_the_day - self.days_before * days, self.at, tzinfo=reminder_tzinfo) + + if isinstance(start, datetime): + minutes_before_start = int((start - reminder_time).total_seconds() / 60) + else: + minutes_before_start = int((start_of_the_day - reminder_time).total_seconds() / 60) + + return Reminder( + method=self.method, + minutes_before_start=minutes_before_start + ) + + +class EmailReminder(Reminder): + def __init__( + self, + minutes_before_start: int = None, + days_before: int = None, + at: time = None + ): + """Represents email reminder object + + Provide `minutes_before_start` to create "relative" reminder. + Provide `days_before` and `at` to create "absolute" reminder. + + :param minutes_before_start: + Minutes before reminder + :param days_before: + Days before reminder + :param at: + Specific time for a reminder + """ + if not days_before and not at and not minutes_before_start: + minutes_before_start = 60 + super().__init__('email', minutes_before_start, days_before, at) + + +class PopupReminder(Reminder): + def __init__( + self, + minutes_before_start: int = None, + days_before: int = None, + at: time = None + ): + """Represents popup reminder object + + Provide `minutes_before_start` to create "relative" reminder. + Provide `days_before` and `at` to create "absolute" reminder. + + :param minutes_before_start: + Minutes before reminder + :param days_before: + Days before reminder + :param at: + Specific time for a reminder + """ + if not days_before and not at and not minutes_before_start: + minutes_before_start = 30 + super().__init__('popup', minutes_before_start, days_before, at) diff --git a/google-calendar-simple-api/gcsa/serializers/__init__.py b/google-calendar-simple-api/gcsa/serializers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/google-calendar-simple-api/gcsa/serializers/acl_rule_serializer.py b/google-calendar-simple-api/gcsa/serializers/acl_rule_serializer.py new file mode 100644 index 0000000000000000000000000000000000000000..d5dfeb4ff3588aedcc0f0c2c696a59f44b459c68 --- /dev/null +++ b/google-calendar-simple-api/gcsa/serializers/acl_rule_serializer.py @@ -0,0 +1,32 @@ +from gcsa.acl import AccessControlRule +from gcsa.serializers.base_serializer import BaseSerializer + + +class ACLRuleSerializer(BaseSerializer): + type_ = AccessControlRule + + def __init__(self, access_control_rule): + super().__init__(access_control_rule) + + @staticmethod + def _to_json(acl_rule: AccessControlRule): + data = { + "id": acl_rule.id, + "scope": { + "type": acl_rule.scope_type, + "value": acl_rule.scope_value + }, + "role": acl_rule.role + } + data = ACLRuleSerializer._remove_empty_values(data) + return data + + @staticmethod + def _to_object(json_acl_rule): + scope = json_acl_rule.get('scope', {}) + return AccessControlRule( + acl_id=json_acl_rule.get('id'), + scope_type=scope.get('type'), + scope_value=scope.get('value'), + role=json_acl_rule.get('role') + ) diff --git a/google-calendar-simple-api/gcsa/serializers/attachment_serializer.py b/google-calendar-simple-api/gcsa/serializers/attachment_serializer.py new file mode 100644 index 0000000000000000000000000000000000000000..64f6c3689fc683fb61d28df113893da1811c9fda --- /dev/null +++ b/google-calendar-simple-api/gcsa/serializers/attachment_serializer.py @@ -0,0 +1,34 @@ +from gcsa.attachment import Attachment +from .base_serializer import BaseSerializer + + +class AttachmentSerializer(BaseSerializer): + type_ = Attachment + + def __init__(self, attachment): + super().__init__(attachment) + + @staticmethod + def _to_json(attachment: Attachment): + res = { + "fileUrl": attachment.file_url, + "title": attachment.title, + "mimeType": attachment.mime_type, + } + + if attachment.file_id: + res['fileId'] = attachment.file_id + if attachment.icon_link: + res['iconLink'] = attachment.icon_link + + return res + + @staticmethod + def _to_object(json_attachment): + return Attachment( + file_url=json_attachment['fileUrl'], + title=json_attachment.get('title', None), + mime_type=json_attachment.get('mimeType', None), + _icon_link=json_attachment.get('iconLink', None), + _file_id=json_attachment.get('fileId', None) + ) diff --git a/google-calendar-simple-api/gcsa/serializers/attendee_serializer.py b/google-calendar-simple-api/gcsa/serializers/attendee_serializer.py new file mode 100644 index 0000000000000000000000000000000000000000..5004b18086e3907099095f3ca0ee654246e4b26b --- /dev/null +++ b/google-calendar-simple-api/gcsa/serializers/attendee_serializer.py @@ -0,0 +1,34 @@ +from gcsa.attendee import Attendee +from .base_serializer import BaseSerializer + + +class AttendeeSerializer(BaseSerializer): + type_ = Attendee + + def __init__(self, attendee): + super().__init__(attendee) + + @staticmethod + def _to_json(attendee: Attendee): + data = { + 'email': attendee.email, + 'displayName': attendee.display_name, + 'comment': attendee.comment, + 'optional': attendee.optional, + 'resource': attendee.is_resource, + 'additionalGuests': attendee.additional_guests, + 'responseStatus': attendee.response_status + } + return {k: v for k, v in data.items() if v is not None} + + @staticmethod + def _to_object(json_attendee): + return Attendee( + email=json_attendee['email'], + display_name=json_attendee.get('displayName', None), + comment=json_attendee.get('comment', None), + optional=json_attendee.get('optional', None), + is_resource=json_attendee.get('resource', None), + additional_guests=json_attendee.get('additionalGuests', None), + _response_status=json_attendee.get('responseStatus', None) + ) diff --git a/google-calendar-simple-api/gcsa/serializers/base_serializer.py b/google-calendar-simple-api/gcsa/serializers/base_serializer.py new file mode 100644 index 0000000000000000000000000000000000000000..9883a5d5cd82c5075904f26d7264a910e4c99aea --- /dev/null +++ b/google-calendar-simple-api/gcsa/serializers/base_serializer.py @@ -0,0 +1,81 @@ +import re +from abc import ABC, abstractmethod +import json + +import dateutil.parser + + +def _type_to_snake_case(type_): + return re.sub(r'(? None: + pass + + def finalize_options(self) -> None: + pass + + def run(self): + output_path = 'docs/html' + changed_files = [] + cmd = [ + 'sphinx-build', + 'docs/source', output_path, + '--builder', 'html', + '--define', f'version={VERSION}', + '--verbose' + ] + with subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + bufsize=1, + universal_newlines=True + ) as p: + for line in p.stdout: + print(line, end='') + if line.startswith('reading sources... ['): + file_name = line.rsplit(maxsplit=1)[1] + if file_name: + changed_files.append(file_name + '.html') + + index_path = os.path.join(os.getcwd(), output_path, 'index.html') + print('\nIndex:') + print(f'file://{index_path}') + + if changed_files: + print('Update pages:') + for cf in changed_files: + f_path = os.path.join(os.getcwd(), output_path, cf) + print(cf, f'file://{f_path}') + + +with open('README.rst') as f: + long_description = ''.join(f.readlines()) + +DOCS_REQUIRES = [ + 'sphinx', + 'sphinx-rtd-theme', +] + +TEST_REQUIRES = [ + 'setuptools', + 'pytest', + 'pytest-pep8', + 'pytest-cov', + 'pyfakefs', + 'flake8', + 'pep8-naming', + 'twine', + 'tox' +] + +setup( + name='gcsa', + version=VERSION, + keywords='python conference calendar hangouts python-library event conferences google-calendar pip recurrence ' + 'google-calendar-api attendee gcsa', + description='Simple API for Google Calendar management', + long_description=long_description, + author='Yevhen Kuzmovych', + author_email='kuzmovych.yevhen@gmail.com', + license='MIT', + url='https://github.com/kuzmoyev/google-calendar-simple-api', + zip_safe=False, + packages=find_packages(exclude=("tests", "tests.*")), + install_requires=[ + "tzlocal>=4,<5", + "google-api-python-client>=1.8", + "google-auth-httplib2>=0.0.4", + "google-auth-oauthlib>=0.5,<2.0", + "python-dateutil>=2.7", + "beautiful_date>=2.0.0", + ], + extras_require={ + 'dev': [ + *TEST_REQUIRES, + *DOCS_REQUIRES + ], + 'tests': TEST_REQUIRES, + 'docs': DOCS_REQUIRES + }, + classifiers=[ + 'License :: OSI Approved :: MIT License', + 'Natural Language :: English', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + ], + cmdclass={ + 'upload': UploadCommand, + 'docs': BuildDoc, + } +) diff --git a/google-calendar-simple-api/tests/__init__.py b/google-calendar-simple-api/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/google-calendar-simple-api/tests/google_calendar_tests/__init__.py b/google-calendar-simple-api/tests/google_calendar_tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/google-calendar-simple-api/tests/google_calendar_tests/mock_services/__init__.py b/google-calendar-simple-api/tests/google_calendar_tests/mock_services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_acl_requests.py b/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_acl_requests.py new file mode 100644 index 0000000000000000000000000000000000000000..b02ddee53da898517ce8e49e1d68fbaf7e4a0a0d --- /dev/null +++ b/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_acl_requests.py @@ -0,0 +1,81 @@ +from gcsa.acl import AccessControlRule, ACLRole, ACLScopeType +from gcsa.serializers.acl_rule_serializer import ACLRuleSerializer +from .util import executable + + +class MockACLRequests: + """Emulates GoogleCalendar.service.acl()""" + ACL_RULES_PER_PAGE = 3 + + def __init__(self): + self.test_acl_rules = [] + for i in range(4): + self.test_acl_rules.append( + AccessControlRule( + role=ACLRole.READER, + scope_type=ACLScopeType.USER, + acl_id=f'user:mail{i}@gmail.com', + scope_value=f'mail{i}@gmail.com' + ) + ) + self.test_acl_rules.append( + AccessControlRule( + role=ACLRole.READER, + scope_type=ACLScopeType.GROUP, + acl_id=f'group:group-mail{i}@gmail.com', + scope_value=f'group-mail{i}@gmail.com' + ) + ) + + @property + def test_acl_rules_by_id(self): + return {c.id: c for c in self.test_acl_rules} + + @executable + def list(self, pageToken, **_): + """Emulates GoogleCalendar.service.acl().list().execute()""" + page = pageToken or 0 # page number in this case + page_acl_rules = self.test_acl_rules[page * self.ACL_RULES_PER_PAGE:(page + 1) * self.ACL_RULES_PER_PAGE] + next_page = page + 1 if (page + 1) * self.ACL_RULES_PER_PAGE < len(self.test_acl_rules) else None + + return { + 'items': [ + ACLRuleSerializer.to_json(c) + for c in page_acl_rules + ], + 'nextPageToken': next_page + } + + @executable + def get(self, calendarId, ruleId): + """Emulates GoogleCalendar.service.acl().get().execute()""" + try: + return ACLRuleSerializer.to_json(self.test_acl_rules_by_id[ruleId]) + except KeyError: + # shouldn't get here in tests + raise ValueError(f'ACLRule with id {ruleId} does not exist') + + @executable + def insert(self, calendarId, body, sendNotifications): + """Emulates GoogleCalendar.service.acl().insert().execute()""" + acl_rule: AccessControlRule = ACLRuleSerializer.to_object(body) + acl_rule.acl_id = f'{acl_rule.scope_type}:{acl_rule.scope_value}' + self.test_acl_rules.append(acl_rule) + return ACLRuleSerializer.to_json(acl_rule) + + @executable + def update(self, calendarId, ruleId, body, sendNotifications): + """Emulates GoogleCalendar.service.acl().update().execute()""" + acl_rule = ACLRuleSerializer.to_object(body) + for i in range(len(self.test_acl_rules)): + if ruleId == self.test_acl_rules[i].id: + self.test_acl_rules[i] = acl_rule + return ACLRuleSerializer.to_json(acl_rule) + + # shouldn't get here in tests + raise ValueError(f'ACL rule with id {ruleId} does not exist') + + @executable + def delete(self, calendarId, ruleId): + """Emulates GoogleCalendar.service.acl().delete().execute()""" + self.test_acl_rules = [c for c in self.test_acl_rules if c.id != ruleId] diff --git a/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_calendar_list_requests.py b/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_calendar_list_requests.py new file mode 100644 index 0000000000000000000000000000000000000000..94910a2f2564ced84339044d35e9e96c11f6ba3f --- /dev/null +++ b/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_calendar_list_requests.py @@ -0,0 +1,78 @@ +from gcsa.calendar import CalendarListEntry +from gcsa.serializers.calendar_serializer import CalendarListEntrySerializer +from .util import executable + + +class MockCalendarListRequests: + """Emulates GoogleCalendar.service.calendarList()""" + CALENDAR_LIST_ENTRIES_PER_PAGE = 3 + + def __init__(self): + self.test_calendars = [ + CalendarListEntry( + summary_override=f'Summery override {i}', + _summary=f'Secondary {i}', + calendar_id=str(i) + ) + for i in range(7) + ] + self.test_calendars.append( + CalendarListEntry( + summary_override='Primary', + _summary='Primary', + calendar_id='primary' + ) + ) + + @property + def test_calendars_by_id(self): + return {c.id: c for c in self.test_calendars} + + @executable + def list(self, pageToken, **_): + page = pageToken or 0 # page number in this case + page_calendars = self.test_calendars[ + page * self.CALENDAR_LIST_ENTRIES_PER_PAGE:(page + 1) * self.CALENDAR_LIST_ENTRIES_PER_PAGE + ] + next_page = page + 1 if (page + 1) * self.CALENDAR_LIST_ENTRIES_PER_PAGE < len(self.test_calendars) else None + + return { + 'items': [ + CalendarListEntrySerializer.to_json(c) + for c in page_calendars + ], + 'nextPageToken': next_page + } + + @executable + def get(self, calendarId): + """Emulates GoogleCalendar.service.calendarList().get().execute()""" + try: + return CalendarListEntrySerializer.to_json(self.test_calendars_by_id[calendarId]) + except KeyError: + # shouldn't get here in tests + raise ValueError(f'Calendar with id {calendarId} does not exist') + + @executable + def insert(self, body, colorRgbFormat): + """Emulates GoogleCalendar.service.calendarList().insert().execute()""" + calendar = CalendarListEntrySerializer.to_object(body) + self.test_calendars.append(calendar) + return CalendarListEntrySerializer.to_json(calendar) + + @executable + def update(self, calendarId, body, colorRgbFormat): + """Emulates GoogleCalendar.service.calendarList().insert().execute()""" + calendar = CalendarListEntrySerializer.to_object(body) + for i in range(len(self.test_calendars)): + if calendarId == self.test_calendars[i].id: + self.test_calendars[i] = calendar + return CalendarListEntrySerializer.to_json(calendar) + + # shouldn't get here in tests + raise ValueError(f'Calendar with id {calendarId} does not exist') + + @executable + def delete(self, calendarId): + """Emulates GoogleCalendar.service.calendarList().delete().execute()""" + self.test_calendars = [c for c in self.test_calendars if c.id != calendarId] diff --git a/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_calendars_requests.py b/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_calendars_requests.py new file mode 100644 index 0000000000000000000000000000000000000000..542db4e25062976a0f26776ee09cdf645bafc70d --- /dev/null +++ b/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_calendars_requests.py @@ -0,0 +1,81 @@ +from gcsa.calendar import Calendar, AccessRoles +from gcsa.serializers.calendar_serializer import CalendarSerializer +from .util import executable + + +class MockCalendarsRequests: + """Emulates GoogleCalendar.service.calendars()""" + + def __init__(self): + self.test_calendars = [ + Calendar( + summary=f'Secondary {i}', + calendar_id=str(i), + description=f'Description {i}', + location=f'Location {i}', + timezone=f'Timezone {i}', + allowed_conference_solution_types=[ + AccessRoles.FREE_BUSY_READER, + AccessRoles.READER + ] + ) + for i in range(7) + ] + self.test_calendars.append( + Calendar( + summary='Primary', + calendar_id='primary', + description='Description', + location='Location', + timezone='Timezone', + allowed_conference_solution_types=[ + AccessRoles.FREE_BUSY_READER, + AccessRoles.READER, + AccessRoles.WRITER, + AccessRoles.OWNER, + ] + ) + ) + + @property + def test_calendars_by_id(self): + return {c.id: c for c in self.test_calendars} + + @executable + def get(self, calendarId): + """Emulates GoogleCalendar.service.calendars().get().execute()""" + try: + return CalendarSerializer.to_json(self.test_calendars_by_id[calendarId]) + except KeyError: + # shouldn't get here in tests + raise ValueError(f'Calendar with id {calendarId} does not exist') + + @executable + def insert(self, body): + """Emulates GoogleCalendar.service.calendars().insert().execute()""" + calendar = CalendarSerializer.to_object(body) + calendar.calendar_id = str(len(self.test_calendars)) + self.test_calendars.append(calendar) + return CalendarSerializer.to_json(calendar) + + @executable + def update(self, calendarId, body): + """Emulates GoogleCalendar.service.calendars().update().execute()""" + calendar = CalendarSerializer.to_object(body) + for i in range(len(self.test_calendars)): + if calendarId == self.test_calendars[i].id: + self.test_calendars[i] = calendar + return CalendarSerializer.to_json(calendar) + + # shouldn't get here in tests + raise ValueError(f'Calendar with id {calendarId} does not exist') + + @executable + def delete(self, calendarId): + """Emulates GoogleCalendar.service.calendars().delete().execute()""" + self.test_calendars = [c for c in self.test_calendars if c.id != calendarId] + + @executable + def clear(self, calendarId): + """Emulates GoogleCalendar.service.calendars().clear().execute()""" + pass diff --git a/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_colors_requests.py b/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_colors_requests.py new file mode 100644 index 0000000000000000000000000000000000000000..159c6b25196e03669c34ab735284b32a16b8f308 --- /dev/null +++ b/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_colors_requests.py @@ -0,0 +1,27 @@ +from .util import executable + + +class MockColorsRequests: + """Emulates GoogleCalendar.service.colors()""" + + def __init__(self): + self.test_colors = { + 'event': { + '1': {'background': '#a4bdfc', 'foreground': '#1d1d1d'}, + '2': {'background': '#7ae7bf', 'foreground': '#1d1d1d'}, + '3': {'background': '#dbadff', 'foreground': '#1d1d1d'}, + '4': {'background': '#ff887c', 'foreground': '#1d1d1d'}, + }, + 'calendar': { + '1': {'background': '#ac725e', 'foreground': '#1d1d1d'}, + '2': {'background': '#d06b64', 'foreground': '#1d1d1d'}, + '3': {'background': '#f83a22', 'foreground': '#1d1d1d'}, + '4': {'background': '#fa573c', 'foreground': '#1d1d1d'}, + '5': {'background': '#fc573c', 'foreground': '#1d1d1d'}, + } + } + + @executable + def get(self, **_): + """Emulates GoogleCalendar.service.colors().get().execute()""" + return self.test_colors diff --git a/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_events_requests.py b/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_events_requests.py new file mode 100644 index 0000000000000000000000000000000000000000..75a281fc49af711339d65f7f4883fc8befdf9217 --- /dev/null +++ b/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_events_requests.py @@ -0,0 +1,209 @@ +from .util import executable + +import dateutil.parser +from beautiful_date import D, days, years + +from gcsa.attendee import Attendee +from gcsa.event import Event +from gcsa.serializers.event_serializer import EventSerializer +from gcsa.util.date_time_util import ensure_localisation + + +class MockEventsRequests: + """Emulates GoogleCalendar.service.events()""" + + EVENTS_PER_PAGE = 3 + + def __init__(self): + self.test_events = [ + Event( + 'test{}'.format(i), + start=ensure_localisation(D.today()[i:0] + i * days), + event_id=f'event_id_{str(i)}', + _updated=ensure_localisation(D.today()[i:0] + (i + 1) * days), + attendees=[ + Attendee(email='{}@gmail.com'.format(attendee_name.lower()), display_name=attendee_name) + ] if attendee_name else None + ) + for i, attendee_name in zip(range(1, 10), ['John', 'Josh'] + [''] * 8) + ] + + @property + def test_events_by_id(self): + return {e.id: e for e in self.test_events} + + @executable + def instances(self, eventId, **_): + """Emulates GoogleCalendar.service.events().instances().execute()""" + + if eventId == 'event_id_1': + recurring_instances = [ + Event( + 'Recurring event 1', + start=D.today()[:] + 1 * days, + event_id='event_id_1_' + (D.today()[:] + (i + 1) * days).isoformat() + 'Z', + _updated=D.today()[:] + 5 * days, + _recurring_event_id='event_id_1', + + ) for i in range(1, 10) + ] + elif eventId == 'event_id_2': + recurring_instances = [ + Event( + 'Recurring event 2', + start=D.today()[:] + 2 * days, + event_id='event_id_2_' + (D.today()[:] + (i + 2) * days).isoformat() + 'Z', + _updated=D.today()[:] + 5 * days, + _recurring_event_id='event_id_2', + + ) for i in range(1, 5) + ] + else: + # shouldn't get here in tests + raise ValueError(f'Event with id {eventId} does not exist') + + return { + 'items': recurring_instances, + 'nextPageToken': None + } + + @executable + def list(self, timeMin, timeMax, orderBy, singleEvents, pageToken, q, **_): + """Emulates GoogleCalendar.service.events().list().execute()""" + + time_min = dateutil.parser.parse(timeMin) + time_max = dateutil.parser.parse(timeMax) + page = pageToken or 0 # page number in this case + + test_events = self.test_events.copy() + + recurring_event = Event('Recurring event', + start=ensure_localisation(D.today()[:] + 2 * days), + event_id='recurring_id', + _updated=ensure_localisation(D.today()[:] + 3 * days)) + recurring_instances = [ + Event( + recurring_event.summary, + start=recurring_event.start + i * days, + event_id=recurring_event.id + '_' + (recurring_event.start + i * days).isoformat() + 'Z', + _updated=recurring_event.updated, + _recurring_event_id=recurring_event.id, + + ) for i in range(10) + ] + + if singleEvents: + test_events.extend(recurring_instances) + else: + test_events.append(recurring_event) + + event_in_a_year = Event( + 'test42', + start=ensure_localisation(D.today()[:] + 1 * years + 2 * days), + event_id='42', + _updated=ensure_localisation(D.today()[:] + 1 * years + 3 * days), + attendees=[ + Attendee(email='frank@gmail.com', display_name='Frank') + ] + ) + test_events.append(event_in_a_year) + + def _filter(e): + return ( + (time_min <= e.start and e.end < time_max) and + ( + not q or + q in e.summary or + (e.description and q in e.description) or + (e.attendees and any((a.display_name and q in a.display_name) for a in e.attendees)) + ) + ) + + def _sort_key(e): + if orderBy is None: + return e.id + if orderBy == 'startTime': + return e.start + if orderBy == 'updated': + return e.updated + + filtered_events = list(filter(_filter, test_events)) + ordered_events = sorted(filtered_events, key=_sort_key) + + def serialize(event): + event_json = EventSerializer.to_json(event) + # Add readonly fields to event json + event_json['updated'] = event.updated.isoformat() + event_json['recurringEventId'] = event.recurring_event_id + return event_json + serialized_events = list(map(serialize, ordered_events)) + + current_page_events = serialized_events[page * self.EVENTS_PER_PAGE:(page + 1) * self.EVENTS_PER_PAGE] + next_page = page + 1 if (page + 1) * self.EVENTS_PER_PAGE < len(serialized_events) else None + return { + 'items': current_page_events, + 'nextPageToken': next_page + } + + @executable + def get(self, eventId, **_): + """Emulates GoogleCalendar.service.events().get().execute()""" + try: + return EventSerializer.to_json(self.test_events_by_id[eventId]) + except KeyError: + # shouldn't get here in tests + raise ValueError(f'Event with id {eventId} does not exist') + + @executable + def insert(self, body, **_): + """Emulates GoogleCalendar.service.events().insert().execute()""" + event = EventSerializer.to_object(body) + + if event.id is None: + event.event_id = f'event_id_{len(self.test_events) + 1}' + else: + assert event.id not in self.test_events_by_id + + self.test_events.append(event) + return EventSerializer.to_json(event) + + @executable + def quickAdd(self, text, **_): + """Emulates GoogleCalendar.service.events().quickAdd().execute()""" + summary, start = text.split(' at ') + event = Event( + summary, + start=dateutil.parser.parse(start) + ) + + event.event_id = f'event_id_{len(self.test_events) + 1}' + self.test_events.append(event) + return EventSerializer.to_json(event) + + @executable + def update(self, eventId, body, **_): + """Emulates GoogleCalendar.service.events().update().execute()""" + + updated_event = EventSerializer.to_object(body) + for i in range(len(self.test_events)): + if eventId == self.test_events[i].id: + self.test_events[i] = updated_event + return EventSerializer.to_json(updated_event) + + # shouldn't get here in tests + raise ValueError(f'Event with id {eventId} does not exist') + + @executable + def import_(self, body, **_): + """Emulates GoogleCalendar.service.events().import_().execute()""" + return self.insert(body).execute() + + @executable + def move(self, eventId, destination, **_): + """Emulates GoogleCalendar.service.events().move().execute()""" + return self.get(eventId=eventId).execute() + + @executable + def delete(self, eventId, **_): + """Emulates GoogleCalendar.service.events().delete().execute()""" + self.test_events = [e for e in self.test_events if e.id != eventId] diff --git a/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_free_busy_requests.py b/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_free_busy_requests.py new file mode 100644 index 0000000000000000000000000000000000000000..06c0acf63f2c229c705677fb5790778d3f5927a7 --- /dev/null +++ b/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_free_busy_requests.py @@ -0,0 +1,88 @@ +from typing import List + +import dateutil.parser +from beautiful_date import D, days, hours + +from gcsa.free_busy import FreeBusy, TimeRange +from gcsa.serializers.free_busy_serializer import FreeBusySerializer +from gcsa.util.date_time_util import ensure_localisation +from .util import executable, time_range_within + +NOT_FOUND_ERROR = [ + { + "domain": "global", + "reason": "notFound" + } +] + + +class MockFreeBusyRequests: + """Emulates GoogleCalendar.service.freebusy()""" + + def __init__(self): + now = ensure_localisation(D.now()) + self.groups = { + 'group1': ['primary', 'calendar2'], + 'group2': ['calendar3', 'calendar4'] + } + self.calendars = { + 'primary': [ + TimeRange(now - 1 * days, now - 1 * days + 1 * hours), + TimeRange(now + 1 * hours, now + 2 * hours), + TimeRange(now + 1 * days + 1 * hours, now + 1 * days + 2 * hours), + TimeRange(now + 15 * days + 1 * hours, now + 15 * days + 2 * hours), + ], + 'calendar2': [ + TimeRange(now - 1 * days, now - 1 * days + 1 * hours), + TimeRange(now + 1 * hours, now + 2 * hours), + TimeRange(now + 1 * days + 1 * hours, now + 1 * days + 2 * hours), + TimeRange(now + 15 * days + 1 * hours, now + 15 * days + 2 * hours), + ], + 'calendar3': [ + TimeRange(now - 1 * days, now - 1 * days + 1 * hours), + TimeRange(now + 1 * hours, now + 2 * hours), + TimeRange(now + 1 * days + 1 * hours, now + 1 * days + 2 * hours), + TimeRange(now + 15 * days + 1 * hours, now + 15 * days + 2 * hours), + ], + 'calendar4': [ + TimeRange(now - 1 * days, now - 1 * days + 1 * hours), + TimeRange(now + 1 * hours, now + 2 * hours), + TimeRange(now + 1 * days + 1 * hours, now + 1 * days + 2 * hours), + TimeRange(now + 15 * days + 1 * hours, now + 15 * days + 2 * hours), + ], + } + + @executable + def query(self, body): + """Emulates GoogleCalendar.service.freebusy().query().execute()""" + time_min = dateutil.parser.parse(body['timeMin']) + time_max = dateutil.parser.parse(body['timeMax']) + items = body['items'] + + request_groups = [i['id'] for i in items if i['id'].startswith('group')] + request_calendars = {i['id'] for i in items if not i['id'].startswith('group')} + + groups = {gn: g for gn, g in self.groups.items() if gn in request_groups} + group_calendars = set(c for g in groups.values() for c in g) + calendars = { + cn: self._filter_ranges(c, time_min, time_max) + for cn, c in self.calendars.items() + if cn in request_calendars | group_calendars + } + + calendars_errors = {c: NOT_FOUND_ERROR for c in request_calendars if c not in calendars} + groups_errors = {g: NOT_FOUND_ERROR for g in request_groups if g not in groups} + + fb_json = FreeBusySerializer.to_json(FreeBusy( + time_min=time_min, + time_max=time_max, + groups=groups, + calendars=calendars, + calendars_errors=calendars_errors, + groups_errors=groups_errors + )) + return fb_json + + @staticmethod + def _filter_ranges(time_ranges: List[TimeRange], time_min, time_max): + return [tr for tr in time_ranges if time_range_within(tr, time_min, time_max)] diff --git a/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_service.py b/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_service.py new file mode 100644 index 0000000000000000000000000000000000000000..344b91d8a3d9183b5cf478375dd7bc47f7df4812 --- /dev/null +++ b/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_service.py @@ -0,0 +1,41 @@ +from .mock_acl_requests import MockACLRequests +from .mock_calendar_list_requests import MockCalendarListRequests +from .mock_calendars_requests import MockCalendarsRequests +from .mock_colors_requests import MockColorsRequests +from .mock_events_requests import MockEventsRequests +from .mock_free_busy_requests import MockFreeBusyRequests +from .mock_settings_requests import MockSettingsRequests + + +class MockService: + """Emulates GoogleCalendar.service""" + + def __init__(self): + self._events = MockEventsRequests() + self._calendars = MockCalendarsRequests() + self._calendar_list = MockCalendarListRequests() + self._colors = MockColorsRequests() + self._settings = MockSettingsRequests() + self._acl = MockACLRequests() + self._free_busy = MockFreeBusyRequests() + + def events(self): + return self._events + + def calendars(self): + return self._calendars + + def calendarList(self): + return self._calendar_list + + def colors(self): + return self._colors + + def settings(self): + return self._settings + + def acl(self): + return self._acl + + def freebusy(self): + return self._free_busy diff --git a/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_settings_requests.py b/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_settings_requests.py new file mode 100644 index 0000000000000000000000000000000000000000..b48dfec6b1721c35ffbfd5eaf81b9bd0b253d6bc --- /dev/null +++ b/google-calendar-simple-api/tests/google_calendar_tests/mock_services/mock_settings_requests.py @@ -0,0 +1,26 @@ +from .util import executable + + +class MockSettingsRequests: + """Emulates GoogleCalendar.service.settings()""" + + @executable + def list(self, **_): + """Emulates GoogleCalendar.service.settings().list().execute()""" + return { + "nextPageToken": None, + "items": [ + {'id': 'autoAddHangouts', 'value': True}, + {'id': 'dateFieldOrder', 'value': 'DMY'}, + {'id': 'defaultEventLength', 'value': 45}, + {'id': 'format24HourTime', 'value': True}, + {'id': 'hideInvitations', 'value': True}, + {'id': 'hideWeekends', 'value': True}, + {'id': 'locale', 'value': 'cz'}, + {'id': 'remindOnRespondedEventsOnly', 'value': True}, + {'id': 'showDeclinedEvents', 'value': False}, + {'id': 'timezone', 'value': 'Europe/Prague'}, + {'id': 'useKeyboardShortcuts', 'value': False}, + {'id': 'weekStart', 'value': 1} + ] + } diff --git a/google-calendar-simple-api/tests/google_calendar_tests/mock_services/util.py b/google-calendar-simple-api/tests/google_calendar_tests/mock_services/util.py new file mode 100644 index 0000000000000000000000000000000000000000..b3ec9c25687ff116a1fe1dd4876eb4abb08413c7 --- /dev/null +++ b/google-calendar-simple-api/tests/google_calendar_tests/mock_services/util.py @@ -0,0 +1,36 @@ +class MockToken: + def __init__(self, valid, refresh_token='refresh_token'): + self.valid = valid + self.expired = not valid + self.refresh_token = refresh_token + + def refresh(self, _): + self.valid = True + self.expired = False + + +def executable(fn): + """Decorator that stores data received from the function in object that returns that data when + called its `execute` method. Emulates HttpRequest from googleapiclient.""" + + class Executable: + def __init__(self, data): + self.data = data + + def execute(self): + return self.data + + def wrapper(*args, **kwargs): + data = fn(*args, **kwargs) + return Executable(data) + + return wrapper + + +def within(dt, time_min, time_max): + return time_min <= dt <= time_max + + +def time_range_within(tr, time_min, time_max): + start, end = tr + return within(start, time_min, time_max) and within(end, time_min, time_max) diff --git a/google-calendar-simple-api/tests/google_calendar_tests/test_acl_service.py b/google-calendar-simple-api/tests/google_calendar_tests/test_acl_service.py new file mode 100644 index 0000000000000000000000000000000000000000..b2263eb8d9f2f877c7c94b53a0a28b81efeea751 --- /dev/null +++ b/google-calendar-simple-api/tests/google_calendar_tests/test_acl_service.py @@ -0,0 +1,71 @@ +from gcsa.acl import AccessControlRule, ACLRole, ACLScopeType +from tests.google_calendar_tests.test_case_with_mocked_service import TestCaseWithMockedService + + +class TestACLService(TestCaseWithMockedService): + def test_get_access_control_list(self): + acl_rules = list(self.gc.get_acl_rules()) + self.assertEqual(len(acl_rules), 8) + + def test_get_acl_rule(self): + acl_rule = self.gc.get_acl_rule(rule_id='user:mail2@gmail.com') + + self.assertEqual(acl_rule.acl_id, 'user:mail2@gmail.com') + self.assertEqual(acl_rule.role, 'reader') + self.assertEqual(acl_rule.scope_type, 'user') + self.assertEqual(acl_rule.scope_value, 'mail2@gmail.com') + + def test_add_acl_rule(self): + acl_rule = AccessControlRule( + role=ACLRole.WRITER, + scope_type=ACLScopeType.DOMAIN, + scope_value='test.com' + ) + + acl_rule = self.gc.add_acl_rule(acl_rule) + + self.assertEqual(acl_rule.acl_id, 'domain:test.com') + self.assertEqual(acl_rule.role, 'writer') + self.assertEqual(acl_rule.scope_type, 'domain') + self.assertEqual(acl_rule.scope_value, 'test.com') + + def test_update_acl_rule(self): + acl_rule = self.gc.get_acl_rule(rule_id='user:mail2@gmail.com') + acl_rule.role = ACLRole.FREE_BUSY_READER + + self.gc.update_acl_rule(acl_rule) + acl_rule = self.gc.get_acl_rule(rule_id='user:mail2@gmail.com') + + self.assertEqual(acl_rule.acl_id, 'user:mail2@gmail.com') + self.assertEqual(acl_rule.role, 'freeBusyReader') + self.assertEqual(acl_rule.scope_type, 'user') + self.assertEqual(acl_rule.scope_value, 'mail2@gmail.com') + + def test_delete_acl_rule(self): + self.gc.delete_acl_rule(acl_rule='user:mail2@gmail.com') + self.gc.delete_acl_rule( + acl_rule=AccessControlRule( + role=ACLRole.READER, + scope_type=ACLScopeType.GROUP, + acl_id='group:group-mail1@gmail.com', + scope_value='group-mail1@gmail.com' + ) + ) + deleted_ids = ('user:mail2@gmail.com', 'group:group-mail1@gmail.com') + + acl_rules = list(self.gc.get_acl_rules()) + self.assertEqual(len(acl_rules), 6) + self.assertTrue(all(r.id not in deleted_ids for r in acl_rules)) + + acl_rule = AccessControlRule( + role=ACLRole.READER, + scope_type=ACLScopeType.GROUP, + scope_value='group-mail1@gmail.com' + ) + with self.assertRaises(ValueError): + # no acl_id + self.gc.delete_acl_rule(acl_rule) + + with self.assertRaises(TypeError): + # should be a AccessControlRule or acl rule id as a string + self.gc.delete_acl_rule(acl_rule=None) diff --git a/google-calendar-simple-api/tests/google_calendar_tests/test_authentication.py b/google-calendar-simple-api/tests/google_calendar_tests/test_authentication.py new file mode 100644 index 0000000000000000000000000000000000000000..d1005094d31f546b3cef34345a42084efc2e45b9 --- /dev/null +++ b/google-calendar-simple-api/tests/google_calendar_tests/test_authentication.py @@ -0,0 +1,103 @@ +import pickle +from os import path +from unittest.mock import patch + +from pyfakefs.fake_filesystem_unittest import TestCase + +from gcsa.google_calendar import GoogleCalendar +from tests.google_calendar_tests.mock_services.util import MockToken + + +class TestGoogleCalendarCredentials(TestCase): + + def setUp(self): + self.setUpPyfakefs() + + self.credentials_dir = path.join(path.expanduser('~'), '.credentials') + self.credentials_path = path.join(self.credentials_dir, 'credentials.json') + self.fs.create_dir(self.credentials_dir) + self.fs.create_file(self.credentials_path) + + self.valid_token_path = path.join(self.credentials_dir, 'valid_token.pickle') + self.expired_token_path = path.join(self.credentials_dir, 'expired_token.pickle') + + with open(self.valid_token_path, 'wb') as token_file: + pickle.dump(MockToken(valid=True), token_file) + with open(self.expired_token_path, 'wb') as token_file: + pickle.dump(MockToken(valid=False), token_file) + + self._add_mocks() + + def _add_mocks(self): + self.build_patcher = patch('googleapiclient.discovery.build', return_value=None).start() + + class MockAuthFlow: + def run_local_server(self, *args, **kwargs): + return MockToken(valid=True) + + self.from_client_secrets_file_patcher = patch( + 'google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file', + return_value=MockAuthFlow() + ).start() + + def tearDown(self): + self.build_patcher.stop() + self.from_client_secrets_file_patcher.stop() + + def test_with_given_credentials(self): + GoogleCalendar(credentials=MockToken(valid=True)) + self.assertFalse(self.from_client_secrets_file_patcher.called) + + def test_with_given_credentials_expired(self): + gc = GoogleCalendar(credentials=MockToken(valid=False)) + self.assertTrue(gc.credentials.valid) + self.assertFalse(gc.credentials.expired) + + def test_get_default_credentials_exist(self): + self.assertEqual( + self.credentials_path, + GoogleCalendar._get_default_credentials_path() + ) + + def test_get_default_credentials_path_not_exist(self): + self.fs.reset() + with self.assertRaises(FileNotFoundError): + GoogleCalendar._get_default_credentials_path() + + def test_get_default_credentials_not_exist(self): + self.fs.remove(self.credentials_path) + with self.assertRaises(FileNotFoundError): + GoogleCalendar._get_default_credentials_path() + + def test_get_default_credentials_client_secrets(self): + self.fs.remove(self.credentials_path) + client_secret_path = path.join(self.credentials_dir, 'client_secret_1234.json') + self.fs.create_file(client_secret_path) + self.assertEqual( + client_secret_path, + GoogleCalendar._get_default_credentials_path() + ) + + def test_get_default_credentials_multiple_client_secrets(self): + self.fs.remove(self.credentials_path) + self.fs.create_file(path.join(self.credentials_dir, 'client_secret_1234.json')) + self.fs.create_file(path.join(self.credentials_dir, 'client_secret_12345.json')) + with self.assertRaises(ValueError): + GoogleCalendar._get_default_credentials_path() + + def test_get_token_valid(self): + gc = GoogleCalendar(token_path=self.valid_token_path) + self.assertTrue(gc.credentials.valid) + self.assertFalse(self.from_client_secrets_file_patcher.called) + + def test_get_token_expired(self): + gc = GoogleCalendar(token_path=self.expired_token_path) + self.assertTrue(gc.credentials.valid) + self.assertFalse(gc.credentials.expired) + self.assertFalse(self.from_client_secrets_file_patcher.called) + + def test_get_token_invalid_refresh(self): + gc = GoogleCalendar(credentials_path=self.credentials_path) + self.assertTrue(gc.credentials.valid) + self.assertFalse(gc.credentials.expired) + self.assertTrue(self.from_client_secrets_file_patcher.called) diff --git a/google-calendar-simple-api/tests/google_calendar_tests/test_calendar_list_service.py b/google-calendar-simple-api/tests/google_calendar_tests/test_calendar_list_service.py new file mode 100644 index 0000000000000000000000000000000000000000..0b523d090db911df563e310dc2bfaeadea2ca5b2 --- /dev/null +++ b/google-calendar-simple-api/tests/google_calendar_tests/test_calendar_list_service.py @@ -0,0 +1,67 @@ +from gcsa.calendar import CalendarListEntry, Calendar +from tests.google_calendar_tests.test_case_with_mocked_service import TestCaseWithMockedService + + +class TestCalendarListService(TestCaseWithMockedService): + def test_get_calendar_list(self): + calendars = list(self.gc.get_calendar_list()) + self.assertEqual(len(calendars), 8) + self.assertTrue(any(c.id == 'primary' for c in calendars)) + + def test_get_calendar_list_entry(self): + calendar = self.gc.get_calendar_list_entry() + self.assertEqual(calendar.id, 'primary') + self.assertIsInstance(calendar, CalendarListEntry) + + calendar = self.gc.get_calendar('1') + self.assertEqual(calendar.id, '1') + + def test_add_calendar_list_entry(self): + calendar = CalendarListEntry( + calendar_id='test_calendar_list_entry', + _summary='Summary', + summary_override='Summary override' + ) + new_calendar = self.gc.add_calendar_list_entry(calendar) + + self.assertIsNotNone(new_calendar.id) + self.assertEqual(calendar.summary_override, new_calendar.summary_override) + + def test_update_calendar_list_entry(self): + calendar = CalendarListEntry( + calendar_id='test_calendar_list_entry', + _summary='Summary', + summary_override='Summary override' + ) + new_calendar = self.gc.add_calendar_list_entry(calendar) + + self.assertEqual(calendar.summary_override, new_calendar.summary_override) + + new_calendar.summary_override = 'Updated summary override' + updated_calendar = self.gc.update_calendar_list_entry(new_calendar) + + self.assertEqual(new_calendar.summary_override, updated_calendar.summary_override) + + retrieved_updated_calendar = self.gc.get_calendar_list_entry(new_calendar.id) + self.assertEqual(retrieved_updated_calendar.summary_override, updated_calendar.summary_override) + + def test_delete_calendar_list_entry(self): + calendar = Calendar( + summary='Summary' + ) + with self.assertRaises(ValueError): + # no calendar_id + self.gc.delete_calendar_list_entry(calendar) + + calendar = CalendarListEntry( + calendar_id='test_calendar_list_entry', + _summary='Summary', + summary_override='Summary override' + ) + + self.gc.delete_calendar_list_entry(calendar) + self.gc.delete_calendar_list_entry('2') + + with self.assertRaises(TypeError): + # should be a Calendar, CalendarListEntry or calendar id as a string + self.gc.delete_calendar_list_entry(calendar=None) diff --git a/google-calendar-simple-api/tests/google_calendar_tests/test_calendars_service.py b/google-calendar-simple-api/tests/google_calendar_tests/test_calendars_service.py new file mode 100644 index 0000000000000000000000000000000000000000..24570283f0f57fdd61bd5171308b9894e6843acc --- /dev/null +++ b/google-calendar-simple-api/tests/google_calendar_tests/test_calendars_service.py @@ -0,0 +1,72 @@ +from gcsa.calendar import Calendar, AccessRoles +from tests.google_calendar_tests.test_case_with_mocked_service import TestCaseWithMockedService + + +class TestCalendarsService(TestCaseWithMockedService): + def test_get_calendar(self): + calendar = self.gc.get_calendar() + self.assertEqual(calendar.id, 'primary') + self.assertIsInstance(calendar, Calendar) + + calendar = self.gc.get_calendar('1') + self.assertEqual(calendar.id, '1') + + def test_add_calendar(self): + calendar = Calendar( + summary='secondary', + description='Description secondary', + location='Location secondary', + timezone='Timezone secondary', + allowed_conference_solution_types=[ + AccessRoles.FREE_BUSY_READER, + AccessRoles.READER + ] + ) + new_calendar = self.gc.add_calendar(calendar) + + self.assertIsNotNone(new_calendar.id) + self.assertEqual(calendar.summary, new_calendar.summary) + + def test_update_calendar(self): + calendar = Calendar( + summary='secondary', + description='Description secondary', + location='Location secondary', + timezone='Timezone secondary', + allowed_conference_solution_types=[ + AccessRoles.FREE_BUSY_READER, + AccessRoles.READER + ] + ) + new_calendar = self.gc.add_calendar(calendar) + + self.assertEqual(calendar.summary, new_calendar.summary) + + new_calendar.summary = 'Updated summary' + updated_calendar = self.gc.update_calendar(new_calendar) + + self.assertEqual(new_calendar.summary, updated_calendar.summary) + + retrieved_updated_calendar = self.gc.get_calendar(new_calendar.id) + self.assertEqual(retrieved_updated_calendar.summary, updated_calendar.summary) + + def test_delete_calendar(self): + calendar = Calendar( + summary='secondary' + ) + + with self.assertRaises(ValueError): + # no calendar_id + self.gc.delete_calendar(calendar) + + new_calendar = self.gc.add_calendar(calendar) + self.gc.delete_calendar(new_calendar) + self.gc.delete_calendar('2') + + with self.assertRaises(TypeError): + # should be a Calendar or calendar id as a string + self.gc.delete_calendar(None) + + def test_clear_calendar(self): + self.gc.clear_calendar() + self.gc.clear() diff --git a/google-calendar-simple-api/tests/google_calendar_tests/test_case_with_mocked_service.py b/google-calendar-simple-api/tests/google_calendar_tests/test_case_with_mocked_service.py new file mode 100644 index 0000000000000000000000000000000000000000..33c28920d57b96f8ab508abedaafa951b05f55a9 --- /dev/null +++ b/google-calendar-simple-api/tests/google_calendar_tests/test_case_with_mocked_service.py @@ -0,0 +1,18 @@ +from unittest.mock import patch + +from pyfakefs.fake_filesystem_unittest import TestCase + +from gcsa.google_calendar import GoogleCalendar +from tests.google_calendar_tests.mock_services.mock_service import MockService +from tests.google_calendar_tests.mock_services.util import MockToken + + +class TestCaseWithMockedService(TestCase): + def setUp(self): + self.build_patcher = patch('googleapiclient.discovery.build', return_value=MockService()) + self.build_patcher.start() + + self.gc = GoogleCalendar(credentials=MockToken(valid=True)) + + def tearDown(self): + self.build_patcher.stop() diff --git a/google-calendar-simple-api/tests/google_calendar_tests/test_colors_service.py b/google-calendar-simple-api/tests/google_calendar_tests/test_colors_service.py new file mode 100644 index 0000000000000000000000000000000000000000..39dd42454589f0e2fe8f586810507e1f28150fa9 --- /dev/null +++ b/google-calendar-simple-api/tests/google_calendar_tests/test_colors_service.py @@ -0,0 +1,11 @@ +from tests.google_calendar_tests.test_case_with_mocked_service import TestCaseWithMockedService + + +class TestColorsService(TestCaseWithMockedService): + def test_list_event_colors(self): + event_colors = self.gc.list_event_colors() + self.assertEqual(len(event_colors), 4) + + def test_list_calendar_colors(self): + calendar_colors = self.gc.list_calendar_colors() + self.assertEqual(len(calendar_colors), 5) diff --git a/google-calendar-simple-api/tests/google_calendar_tests/test_events_service.py b/google-calendar-simple-api/tests/google_calendar_tests/test_events_service.py new file mode 100644 index 0000000000000000000000000000000000000000..5cc04d31a34511a734823e31ab8b54cad3bec7ae --- /dev/null +++ b/google-calendar-simple-api/tests/google_calendar_tests/test_events_service.py @@ -0,0 +1,238 @@ +from beautiful_date import D, days, years, hours + +from gcsa.event import Event +from gcsa.util.date_time_util import ensure_localisation +from tests.google_calendar_tests.test_case_with_mocked_service import TestCaseWithMockedService + + +class TestEventsService(TestCaseWithMockedService): + + def test_get_events_default(self): + events = list(self.gc.get_events()) + self.assertEqual(len(events), 10) + self.assertFalse(any(e.is_recurring_instance for e in events)) + + events = list(self.gc) + self.assertEqual(len(events), 10) + self.assertFalse(any(e.is_recurring_instance for e in events)) + + def test_get_events_time_limits(self): + time_min = ensure_localisation(D.today()[:] + 5 * days) + events = list(self.gc.get_events(time_min=time_min)) + self.assertEqual(len(events), 6) + self.assertTrue(all(e.start >= time_min for e in events)) + + time_min = ensure_localisation(D.today()[:] + 5 * days) + events = list(self.gc[time_min]) + self.assertEqual(len(events), 6) + self.assertTrue(all(e.start >= time_min for e in events)) + + time_max = ensure_localisation(D.today()[:] + 1 * years + 7 * days) + events = list(self.gc.get_events(time_max=time_max)) + self.assertEqual(len(events), 11) + self.assertTrue(all(e.end < time_max for e in events)) + + time_max = ensure_localisation(D.today()[:] + 7 * days) + events = list(self.gc.get_events(time_max=time_max)) + self.assertEqual(len(events), 7) + self.assertTrue(all(e.end < time_max for e in events)) + + events = list(self.gc.get_events(time_min=time_min, time_max=time_max)) + self.assertEqual(len(events), 2) + self.assertTrue(all(time_min <= e.start and e.end < time_max for e in events)) + + events = list(self.gc[time_min:time_max]) + self.assertEqual(len(events), 2) + self.assertTrue(all(time_min <= e.start and e.end < time_max for e in events)) + + time_min = D.today() + 5 * days + time_max = D.today() + 7 * days + events = list(self.gc.get_events(time_min=time_min, time_max=time_max)) + self.assertEqual(len(events), 2) + + time_min = ensure_localisation(time_min[0:0]) + time_max = ensure_localisation(time_max[23:59:59]) + self.assertTrue(all(time_min <= e.start and e.end < time_max for e in events)) + + with self.assertRaises(NotImplementedError): + _ = self.gc[5] + with self.assertRaises(ValueError): + _ = self.gc[5:10] + + def test_get_events_single_events(self): + events = list(self.gc.get_events(single_events=True)) + self.assertEqual(len(events), 19) + self.assertTrue(all(e.is_recurring_instance for e in events if e.summary == 'Recurring event')) + + events = list(self.gc.get_events(single_events=False)) + self.assertEqual(len(events), 10) + self.assertTrue(all(not e.is_recurring_instance for e in events if e.summary == 'Recurring event')) + + with self.assertRaises(ValueError): + # can only be used with single events + list(self.gc.get_events(order_by='startTime')) + + def test_get_events_order_by(self): + events = list(self.gc.get_events(order_by='updated')) + self.assertEqual(len(events), 10) + self.assertEqual(events[0].id, min(events, key=lambda e: e.updated).id) + self.assertEqual(events[-1].id, max(events, key=lambda e: e.updated).id) + + events = list(self.gc[::'updated']) + self.assertEqual(len(events), 10) + self.assertEqual(events[0].id, min(events, key=lambda e: e.updated).id) + self.assertEqual(events[-1].id, max(events, key=lambda e: e.updated).id) + + events = list(self.gc[::'startTime']) + self.assertEqual(len(events), 19) + self.assertEqual(events[0].id, min(events, key=lambda e: e.start).id) + self.assertEqual(events[-1].id, max(events, key=lambda e: e.start).id) + + events = list(self.gc.get_events(order_by='startTime', single_events=True)) + self.assertEqual(len(events), 19) + self.assertEqual(events[0].id, min(events, key=lambda e: e.start).id) + self.assertEqual(events[-1].id, max(events, key=lambda e: e.start).id) + + def test_get_events_query(self): + events = list(self.gc.get_events(query='test4', time_max=D.today()[:] + 2 * years)) + self.assertEqual(len(events), 2) # test4 and test42 + + events = list(self.gc.get_events(query='Jo', time_max=D.today()[:] + 2 * years)) + self.assertEqual(len(events), 2) # with John and Josh + + events = list(self.gc.get_events(query='Josh', time_max=D.today()[:] + 2 * years)) + self.assertEqual(len(events), 1) + + events = list(self.gc.get_events(query='Frank', time_max=D.today()[:] + 2 * years)) + self.assertEqual(len(events), 1) + + def test_get_recurring_instances(self): + events = list(self.gc.get_instances(recurring_event='event_id_1')) + self.assertEqual(len(events), 9) + self.assertTrue(all(e.id.startswith('event_id_1') for e in events)) + + recurring_event = Event( + 'recurring event', + D.today()[:], + event_id='event_id_2' + ) + events = list(self.gc.get_instances(recurring_event=recurring_event)) + self.assertEqual(len(events), 4) + self.assertTrue(all(e.id.startswith('event_id_2') for e in events)) + + recurring_event_without_id = Event( + 'recurring event', + D.today()[:], + ) + with self.assertRaises(ValueError): + list(self.gc.get_instances(recurring_event=recurring_event_without_id)) + + def test_get_event(self): + start = D.today()[:] + end = start + 2 * hours + event = Event( + 'test_event', + start=start, + end=end + ) + new_event = self.gc.add_event(event) + + received_new_event = self.gc.get_event(new_event.id) + self.assertEqual(received_new_event, new_event) + + def test_add_event(self): + start = D.today()[:] + end = start + 2 * hours + event = Event( + 'test_event', + start=start, + end=end + ) + new_event = self.gc.add_event(event) + + self.assertIsNotNone(new_event.id) + + received_new_event = self.gc.get_event(new_event.id) + self.assertEqual(received_new_event, new_event) + + def test_add_quick_event(self): + start = ensure_localisation(D.today()[:]) + summary = 'Breakfast' + event_string = f'{summary} at {start.isoformat()}' + + new_event = self.gc.add_quick_event(event_string) + + self.assertIsNotNone(new_event.id) + self.assertEqual(new_event.summary, summary) + self.assertEqual(new_event.start, start) + + received_new_event = self.gc.get_event(new_event.id) + self.assertEqual(received_new_event, new_event) + + def test_update_event(self): + start = ensure_localisation(D.today()[:]) + summary = 'test_event' + event = Event( + summary, + start=start + ) + new_event = self.gc.add_event(event) + self.assertEqual(new_event.summary, summary) + + new_summary = 'test_event_updated' + new_start = start + 1 * days + + new_event.summary = new_summary + new_event.start = new_start + + updated_event = self.gc.update_event(new_event) + self.assertEqual(updated_event, new_event) + + received_updated_event = self.gc.get_event(new_event.id) + self.assertEqual(received_updated_event, new_event) + + def test_import_event(self): + start = D.today()[:] + end = start + 2 * hours + event = Event( + 'test_event', + start=start, + end=end, + event_id='test_event' + ) + new_event = self.gc.import_event(event) + received_new_event = self.gc.get_event(new_event.id) + self.assertEqual(received_new_event, new_event) + + def test_move_event(self): + start = D.today()[:] + end = start + 2 * hours + event = Event( + 'test_event', + start=start, + end=end, + event_id='test_event_id' + ) + new_event = self.gc.add_event(event) + received_new_event = self.gc.move_event(new_event, destination_calendar_id='test_dest_calendar') + self.assertEqual(received_new_event, new_event) + + def test_delete_event(self): + start = D.today()[:] + end = start + 2 * hours + event = Event( + 'test_event', + start=start, + end=end + ) + with self.assertRaises(ValueError): + # no event_id + self.gc.delete_event(event) + + new_event = self.gc.add_event(event) + self.gc.delete_event(new_event) + self.gc.delete_event('test_event_id') + + with self.assertRaises(TypeError): + # should be event or event id as a string + self.gc.delete_event(start) diff --git a/google-calendar-simple-api/tests/google_calendar_tests/test_free_busy_service.py b/google-calendar-simple-api/tests/google_calendar_tests/test_free_busy_service.py new file mode 100644 index 0000000000000000000000000000000000000000..3d6ec629c2d78b32ebd754698ff8e9e7b43f25d2 --- /dev/null +++ b/google-calendar-simple-api/tests/google_calendar_tests/test_free_busy_service.py @@ -0,0 +1,85 @@ +from datetime import timedelta + +from beautiful_date import D, weeks + +from gcsa.free_busy import FreeBusyQueryError +from gcsa.util.date_time_util import ensure_localisation +from tests.google_calendar_tests.mock_services.util import time_range_within +from tests.google_calendar_tests.test_case_with_mocked_service import TestCaseWithMockedService + + +class TestFreeBusyService(TestCaseWithMockedService): + def test_query_default(self): + free_busy = self.gc.get_free_busy() + + time_min = ensure_localisation(D.now()) + time_max = time_min + 2 * weeks + self.assertAlmostEqual(free_busy.time_min, time_min, delta=timedelta(seconds=5)) + self.assertAlmostEqual(free_busy.time_max, time_max, delta=timedelta(seconds=5)) + + self.assertEqual(len(free_busy.calendars), 1) + self.assertEqual(len(free_busy.calendars['primary']), 2) + self.assertTrue( + all( + time_range_within(tr, time_min, time_max) + for tr in free_busy.calendars['primary'] + ) + ) + + def test_query_with_resource_ids(self): + time_min = ensure_localisation(D.now()) + time_max = time_min + 2 * weeks + + free_busy = self.gc.get_free_busy(resource_ids='calendar3') + + self.assertEqual(len(free_busy.calendars), 1) + self.assertIn('calendar3', free_busy.calendars) + self.assertTrue( + all( + time_range_within(tr, time_min, time_max) + for tr in free_busy.calendars['calendar3'] + ) + ) + + free_busy = self.gc.get_free_busy(resource_ids=['primary', 'group2']) + + self.assertEqual(len(free_busy.calendars), 3) + # by calendar id + self.assertIn('primary', free_busy.calendars) + # by group + self.assertIn('calendar3', free_busy.calendars) + self.assertIn('calendar4', free_busy.calendars) + + self.assertTrue( + all( + time_range_within(tr, time_min, time_max) + for tr in free_busy.calendars['primary'] + ) + ) + self.assertTrue( + all( + time_range_within(tr, time_min, time_max) + for tr in free_busy.calendars['calendar3'] + ) + ) + + self.assertTrue(len(free_busy.groups), 1) + self.assertIn('group2', free_busy.groups) + + def test_query_with_errors(self): + with self.assertRaises(FreeBusyQueryError) as cm: + self.gc.get_free_busy(resource_ids=['calendar-unknown']) + fb_exception = cm.exception + self.assertIn('calendar-unknown', fb_exception.calendars_errors) + + with self.assertRaises(FreeBusyQueryError) as cm: + self.gc.get_free_busy(resource_ids=['group-unknown']) + fb_exception = cm.exception + self.assertIn('group-unknown', fb_exception.groups_errors) + + def test_query_with_errors_ignored(self): + free_busy = self.gc.get_free_busy(resource_ids=['calendar-unknown', 'group-unknown'], ignore_errors=True) + self.assertIn('calendar-unknown', free_busy.calendars_errors) + self.assertIn('group-unknown', free_busy.groups_errors) + self.assertFalse(free_busy.calendars) + self.assertFalse(free_busy.groups) diff --git a/google-calendar-simple-api/tests/google_calendar_tests/test_settings_service.py b/google-calendar-simple-api/tests/google_calendar_tests/test_settings_service.py new file mode 100644 index 0000000000000000000000000000000000000000..0e927ecaf5cee9122d370e2970854787826eab19 --- /dev/null +++ b/google-calendar-simple-api/tests/google_calendar_tests/test_settings_service.py @@ -0,0 +1,18 @@ +from tests.google_calendar_tests.test_case_with_mocked_service import TestCaseWithMockedService + + +class TestSettingsService(TestCaseWithMockedService): + def test_get_settings(self): + settings = self.gc.get_settings() + self.assertTrue(settings.auto_add_hangouts) + self.assertEqual(settings.date_field_order, 'DMY') + self.assertEqual(settings.default_event_length, 45) + self.assertTrue(settings.format24_hour_time) + self.assertTrue(settings.hide_invitations) + self.assertTrue(settings.hide_weekends) + self.assertEqual(settings.locale, 'cz') + self.assertTrue(settings.remind_on_responded_events_only) + self.assertFalse(settings.show_declined_events) + self.assertEqual(settings.timezone, 'Europe/Prague') + self.assertFalse(settings.use_keyboard_shortcuts) + self.assertEqual(settings.week_start, 1) diff --git a/google-calendar-simple-api/tests/test_acl_rule.py b/google-calendar-simple-api/tests/test_acl_rule.py new file mode 100644 index 0000000000000000000000000000000000000000..2d8774ee965025b36cf2d80f577d91766d8bd573 --- /dev/null +++ b/google-calendar-simple-api/tests/test_acl_rule.py @@ -0,0 +1,48 @@ +from unittest import TestCase + +from gcsa.acl import AccessControlRule, ACLRole, ACLScopeType +from gcsa.serializers.acl_rule_serializer import ACLRuleSerializer + + +class TestACLRule(TestCase): + def test_repr_str(self): + acl_rule = AccessControlRule( + role=ACLRole.READER, + scope_type=ACLScopeType.USER, + scope_value='mail@gmail.com' + ) + self.assertEqual(acl_rule.__repr__(), "") + self.assertEqual(acl_rule.__str__(), "mail@gmail.com - reader") + + +class TestACLRuleSerializer(TestCase): + def test_to_json(self): + acl_rule = AccessControlRule( + role=ACLRole.READER, + scope_type=ACLScopeType.USER, + acl_id='user:mail@gmail.com', + scope_value='mail@gmail.com' + ) + + acl_rule_json = ACLRuleSerializer.to_json(acl_rule) + self.assertEqual(acl_rule.role, acl_rule_json['role']) + self.assertEqual(acl_rule.scope_type, acl_rule_json['scope']['type']) + self.assertEqual(acl_rule.acl_id, acl_rule_json['id']) + self.assertEqual(acl_rule.scope_value, acl_rule_json['scope']['value']) + + def test_to_object(self): + acl_rule_json = { + 'id': 'user:mail@gmail.com', + 'scope': { + 'type': 'user', + 'value': 'mail@gmail.com' + }, + 'role': 'reader' + } + + acl_rule = ACLRuleSerializer.to_object(acl_rule_json) + + self.assertEqual(acl_rule_json['role'], acl_rule.role) + self.assertEqual(acl_rule_json['scope']['type'], acl_rule.scope_type) + self.assertEqual(acl_rule_json['id'], acl_rule.acl_id) + self.assertEqual(acl_rule_json['scope']['value'], acl_rule.scope_value) diff --git a/google-calendar-simple-api/tests/test_attachment.py b/google-calendar-simple-api/tests/test_attachment.py new file mode 100644 index 0000000000000000000000000000000000000000..b5e916d445797337c14f27f43dfdf23a1657dfbb --- /dev/null +++ b/google-calendar-simple-api/tests/test_attachment.py @@ -0,0 +1,112 @@ +from unittest import TestCase + +from gcsa.attachment import Attachment +from gcsa.serializers.attachment_serializer import AttachmentSerializer + +DOC_URL = 'https://bit.ly/3lZo0Cc' + + +class TestAttachment(TestCase): + + def test_create(self): + attachment = Attachment( + file_url=DOC_URL, + title='My doc', + mime_type="application/vnd.google-apps.document" + ) + self.assertEqual(attachment.title, 'My doc') + + attachment = Attachment( + file_url=DOC_URL, + title='My doc', + mime_type="application/vnd.google-apps.something" + ) + self.assertTrue(attachment.unsupported_mime_type) + + def test_repr_str(self): + attachment = Attachment( + file_url=DOC_URL, + title='My doc', + mime_type="application/vnd.google-apps.document" + ) + self.assertEqual(attachment.__repr__(), "") + self.assertEqual(attachment.__str__(), "'My doc' - 'https://bit.ly/3lZo0Cc'") + + +class TestAttachmentSerializer(TestCase): + + def test_to_json(self): + attachment = Attachment( + file_url=DOC_URL, + title='My doc', + mime_type="application/vnd.google-apps.document" + ) + attachment_json = { + 'title': 'My doc', + 'fileUrl': DOC_URL, + 'mimeType': "application/vnd.google-apps.document" + } + self.assertDictEqual(AttachmentSerializer.to_json(attachment), attachment_json) + + attachment = Attachment( + file_url=DOC_URL, + title='My doc2', + mime_type="application/vnd.google-apps.drawing", + _icon_link="https://some_link.com", + _file_id='abc123' + ) + attachment_json = { + 'title': 'My doc2', + 'fileUrl': DOC_URL, + 'mimeType': "application/vnd.google-apps.drawing", + 'iconLink': "https://some_link.com", + 'fileId': 'abc123' + } + serializer = AttachmentSerializer(attachment) + self.assertDictEqual(serializer.get_json(), attachment_json) + + def test_to_object(self): + attachment_json = { + 'title': 'My doc', + 'fileUrl': DOC_URL, + 'mimeType': "application/vnd.google-apps.document" + } + attachment = AttachmentSerializer.to_object(attachment_json) + + self.assertEqual(attachment.title, 'My doc') + self.assertEqual(attachment.file_url, DOC_URL) + self.assertEqual(attachment.mime_type, "application/vnd.google-apps.document") + self.assertIsNone(attachment.icon_link) + self.assertIsNone(attachment.file_id) + + attachment_json = { + 'title': 'My doc2', + 'fileUrl': DOC_URL, + 'mimeType': "application/vnd.google-apps.drawing", + 'iconLink': "https://some_link.com", + 'fileId': 'abc123' + } + serializer = AttachmentSerializer(attachment_json) + attachment = serializer.get_object() + + self.assertEqual(attachment.title, 'My doc2') + self.assertEqual(attachment.file_url, DOC_URL) + self.assertEqual(attachment.mime_type, "application/vnd.google-apps.drawing") + self.assertEqual(attachment.icon_link, "https://some_link.com") + self.assertEqual(attachment.file_id, 'abc123') + + attachment_json_str = """{ + "title": "My doc3", + "fileUrl": "%s", + "mimeType": "application/vnd.google-apps.drawing", + "iconLink": "https://some_link.com", + "fileId": "abc123" + } + """ % DOC_URL + attachment = AttachmentSerializer.to_object(attachment_json_str) + + self.assertEqual(attachment.title, 'My doc3') + self.assertEqual(attachment.file_url, DOC_URL) + self.assertEqual(attachment.mime_type, "application/vnd.google-apps.drawing") + self.assertEqual(attachment.icon_link, "https://some_link.com") + self.assertEqual(attachment.file_id, 'abc123') diff --git a/google-calendar-simple-api/tests/test_attendee.py b/google-calendar-simple-api/tests/test_attendee.py new file mode 100644 index 0000000000000000000000000000000000000000..a2062bfa588d844cdd9d3b767155290425ec377e --- /dev/null +++ b/google-calendar-simple-api/tests/test_attendee.py @@ -0,0 +1,82 @@ +from unittest import TestCase + +from gcsa.attendee import Attendee, ResponseStatus +from gcsa.serializers.attendee_serializer import AttendeeSerializer + + +class TestAttendee(TestCase): + def test_repr_str(self): + attendee = Attendee( + email='mail@gmail.com', + display_name='Guest', + comment='I do not know him', + optional=True, + additional_guests=2, + _response_status=ResponseStatus.NEEDS_ACTION + ) + self.assertEqual(attendee.__repr__(), "") + self.assertEqual(attendee.__str__(), "'mail@gmail.com' - response: 'needsAction'") + + +class TestAttendeeSerializer(TestCase): + def test_to_json(self): + attendee = Attendee( + email='mail@gmail.com', + display_name='Guest', + comment='I do not know him', + optional=True, + additional_guests=2, + _response_status=ResponseStatus.NEEDS_ACTION + ) + + attendee_json = AttendeeSerializer.to_json(attendee) + + self.assertEqual(attendee.email, attendee_json['email']) + self.assertEqual(attendee.display_name, attendee_json['displayName']) + self.assertEqual(attendee.comment, attendee_json['comment']) + self.assertEqual(attendee.optional, attendee_json['optional']) + self.assertNotIn('resource', attendee_json) + self.assertEqual(attendee.additional_guests, attendee_json['additionalGuests']) + self.assertEqual(attendee.response_status, attendee_json['responseStatus']) + + def test_to_object(self): + attendee_json = { + 'email': 'mail2@gmail.com', + 'displayName': 'Guest2', + 'comment': 'I do not know him either', + 'optional': True, + 'resource': True, + 'additionalGuests': 1, + 'responseStatus': ResponseStatus.ACCEPTED + } + + attendee = AttendeeSerializer.to_object(attendee_json) + + self.assertEqual(attendee_json['email'], attendee.email) + self.assertEqual(attendee_json['displayName'], attendee.display_name) + self.assertEqual(attendee_json['comment'], attendee.comment) + self.assertEqual(attendee_json['optional'], attendee.optional) + self.assertEqual(attendee_json['resource'], attendee.is_resource) + self.assertEqual(attendee_json['additionalGuests'], attendee.additional_guests) + self.assertEqual(attendee_json['responseStatus'], attendee.response_status) + + attendee_json_str = """{ + "email": "mail3@gmail.com", + "displayName": "Guest3", + "comment": "Who are these people?", + "optional": true, + "resource": false, + "additionalGuests": 66, + "responseStatus": "tentative" + }""" + + serializer = AttendeeSerializer(attendee_json_str) + attendee = serializer.get_object() + + self.assertEqual(attendee.email, "mail3@gmail.com") + self.assertEqual(attendee.display_name, "Guest3") + self.assertEqual(attendee.comment, "Who are these people?") + self.assertEqual(attendee.optional, True) + self.assertEqual(attendee.is_resource, False) + self.assertEqual(attendee.additional_guests, 66) + self.assertEqual(attendee.response_status, "tentative") diff --git a/google-calendar-simple-api/tests/test_base_serializer.py b/google-calendar-simple-api/tests/test_base_serializer.py new file mode 100644 index 0000000000000000000000000000000000000000..b452fb0aa39a77a814be7fdd65a47ce5fed5668b --- /dev/null +++ b/google-calendar-simple-api/tests/test_base_serializer.py @@ -0,0 +1,84 @@ +from unittest import TestCase + +from gcsa.serializers.base_serializer import BaseSerializer + + +class TestBaseSerializer(TestCase): + def test_ensure_dict(self): + json_str = """ + { + "key": "value", + "list": [1, 2, 4] + } + """ + + json_dict = { + "key": "value", + "list": [1, 2, 4] + } + + json_object = (1, 2, 3) # non-json object + + self.assertDictEqual(BaseSerializer.ensure_dict(json_str), json_dict) + self.assertDictEqual(BaseSerializer.ensure_dict(json_dict), json_dict) + + with self.assertRaises(TypeError): + BaseSerializer.ensure_dict(json_object) + + def test_subclass(self): + class Apple: + pass + + # should not raise any exceptions + class AppleSerializer(BaseSerializer): + type_ = Apple + + def __init__(self, apple): + super().__init__(apple) + + @staticmethod + def _to_json(obj): + pass + + @staticmethod + def _to_object(json_): + pass + + with self.assertRaises(AssertionError): + # type_ not defined + class PeachSerializer(BaseSerializer): + def __init__(self, peach): + super().__init__(peach) + + @staticmethod + def _to_json(obj): + pass + + @staticmethod + def _to_object(json_): + pass + + class Watermelon: + pass + + with self.assertRaises(AssertionError): + # __init__ parameter should be "apple" + class WatermelonSerializer(BaseSerializer): + type_ = Watermelon + + def __init__(self, peach): + super().__init__(peach) + + @staticmethod + def _to_json(obj): + pass + + @staticmethod + def _to_object(json_): + pass + + with self.assertRaises(TypeError): + AppleSerializer(Watermelon) + + with self.assertRaises(TypeError): + AppleSerializer.to_json(Watermelon) diff --git a/google-calendar-simple-api/tests/test_calendar.py b/google-calendar-simple-api/tests/test_calendar.py new file mode 100644 index 0000000000000000000000000000000000000000..33420bfeb90fd18f7efdb37af101992aa9592cc7 --- /dev/null +++ b/google-calendar-simple-api/tests/test_calendar.py @@ -0,0 +1,378 @@ +from unittest import TestCase + +from gcsa.calendar import Calendar, CalendarListEntry, NotificationType, AccessRoles +from gcsa.conference import SolutionType +from gcsa.reminders import EmailReminder, PopupReminder +from gcsa.serializers.calendar_serializer import CalendarSerializer, CalendarListEntrySerializer + +TEST_TIMEZONE = 'Pacific/Fiji' +TEST_ALLOWED_CONFERENCE_SOLUTION_TYPES = [SolutionType.HANGOUT, SolutionType.NAMED_HANGOUT] +TEST_NOTIFICATION_TYPES = [NotificationType.EVENT_CREATION, NotificationType.EVENT_CHANGE] +TEST_ACCESS_ROLE = AccessRoles.OWNER + + +class TestCalendar(TestCase): + def test_init(self): + c = Calendar( + summary='Summary', + calendar_id='Calendar id', + description='Description', + location='Fiji', + timezone=TEST_TIMEZONE, + allowed_conference_solution_types=TEST_ALLOWED_CONFERENCE_SOLUTION_TYPES + ) + + self.assertEqual(c.summary, 'Summary') + self.assertEqual(c.calendar_id, 'Calendar id') + self.assertEqual(c.id, 'Calendar id') + self.assertEqual(c.description, 'Description') + self.assertEqual(c.location, 'Fiji') + self.assertEqual(c.timezone, TEST_TIMEZONE) + self.assertListEqual(c.allowed_conference_solution_types, TEST_ALLOWED_CONFERENCE_SOLUTION_TYPES) + + def test_to_calendar_list_entry(self): + c = Calendar( + summary='Summary', + calendar_id='Calendar id', + description='Description', + location='Fiji', + timezone=TEST_TIMEZONE, + allowed_conference_solution_types=TEST_ALLOWED_CONFERENCE_SOLUTION_TYPES + ) + + cle = c.to_calendar_list_entry( + summary_override='Summary override', + color_id='1', + background_color='#123123', + foreground_color='#234234', + hidden=False, + selected=True, + default_reminders=[EmailReminder(60), PopupReminder(15)], + notification_types=TEST_NOTIFICATION_TYPES + ) + self.assertIsInstance(cle, CalendarListEntry) + + self.assertEqual(cle.summary_override, 'Summary override') + self.assertEqual(cle.color_id, '1') + self.assertEqual(cle.background_color, '#123123') + self.assertEqual(cle.foreground_color, '#234234') + self.assertFalse(cle.hidden) + self.assertTrue(cle.selected) + self.assertEqual(cle.default_reminders, [EmailReminder(60), PopupReminder(15)]) + self.assertEqual(cle.notification_types, TEST_NOTIFICATION_TYPES) + + c_without_id = Calendar( + summary='Summary', + ) + with self.assertRaises(ValueError): + c_without_id.to_calendar_list_entry() + + def test_repr_str(self): + c = Calendar( + summary='Summary', + calendar_id='Calendar id', + description='Description' + ) + self.assertEqual(str(c), 'Summary - Description') + self.assertEqual(repr(c), '') + + def test_eq(self): + c1 = Calendar( + summary='Summary', + calendar_id='Calendar id', + description='Description', + location='Fiji', + timezone=TEST_TIMEZONE, + allowed_conference_solution_types=TEST_ALLOWED_CONFERENCE_SOLUTION_TYPES + ) + c2 = Calendar( + summary='Summary2', + calendar_id='Calendar id2', + description='Description2', + location='Fiji', + timezone=TEST_TIMEZONE, + allowed_conference_solution_types=TEST_ALLOWED_CONFERENCE_SOLUTION_TYPES + ) + self.assertEqual(c1, c1) + self.assertNotEqual(c1, c2) + self.assertNotEqual(c1, 'Calendar') + + +class TestCalendarSerializer(TestCase): + + def test_to_json(self): + c = Calendar( + summary='Summary', + calendar_id='Calendar id', + description='Description', + location='Fiji', + timezone=TEST_TIMEZONE, + allowed_conference_solution_types=TEST_ALLOWED_CONFERENCE_SOLUTION_TYPES + ) + expected_calendar_json = { + "id": 'Calendar id', + "summary": 'Summary', + "description": 'Description', + "location": 'Fiji', + "timeZone": TEST_TIMEZONE, + "conferenceProperties": { + "allowedConferenceSolutionTypes": TEST_ALLOWED_CONFERENCE_SOLUTION_TYPES + } + } + self.assertDictEqual(CalendarSerializer.to_json(c), expected_calendar_json) + + c = Calendar( + summary='Summary', + description='Description', + timezone=TEST_TIMEZONE + ) + expected_calendar_json = { + "summary": 'Summary', + "description": 'Description', + "timeZone": TEST_TIMEZONE + } + self.assertDictEqual(CalendarSerializer.to_json(c), expected_calendar_json) + + def test_to_object(self): + calendar_json = { + "id": 'Calendar id', + "summary": 'Summary', + "description": 'Description', + "location": 'Fiji', + "timeZone": TEST_TIMEZONE, + "conferenceProperties": { + "allowedConferenceSolutionTypes": TEST_ALLOWED_CONFERENCE_SOLUTION_TYPES + } + } + + serializer = CalendarSerializer(calendar_json) + c = serializer.get_object() + + self.assertEqual(c.summary, 'Summary') + self.assertEqual(c.calendar_id, 'Calendar id') + self.assertEqual(c.description, 'Description') + self.assertEqual(c.location, 'Fiji') + self.assertEqual(c.timezone, TEST_TIMEZONE) + self.assertListEqual(c.allowed_conference_solution_types, TEST_ALLOWED_CONFERENCE_SOLUTION_TYPES) + + calendar_json = """{ + "id": "Calendar id", + "summary": "Summary", + "location": "Fiji" + }""" + + serializer = CalendarSerializer(calendar_json) + c = serializer.get_object() + + self.assertEqual(c.summary, 'Summary') + self.assertEqual(c.calendar_id, 'Calendar id') + self.assertIsNone(c.description) + self.assertEqual(c.location, 'Fiji') + self.assertIsNone(c.timezone) + self.assertIsNone(c.allowed_conference_solution_types) + + +class TestCalendarListEntry(TestCase): + def test_init(self): + c = CalendarListEntry( + summary_override='Summary override', + color_id='1', + background_color='#123123', + foreground_color='#234234', + hidden=False, + selected=True, + default_reminders=[EmailReminder(60), PopupReminder(15)], + notification_types=TEST_NOTIFICATION_TYPES, + _access_role=TEST_ACCESS_ROLE, + _primary=True, + _deleted=False, + + _summary='Summary', + calendar_id='Calendar id', + _description='Description', + _location='Fiji', + _timezone=TEST_TIMEZONE, + _allowed_conference_solution_types=TEST_ALLOWED_CONFERENCE_SOLUTION_TYPES + ) + + self.assertEqual(c.summary_override, 'Summary override') + self.assertEqual(c.color_id, '1') + self.assertEqual(c.background_color, '#123123') + self.assertEqual(c.foreground_color, '#234234') + self.assertFalse(c.hidden) + self.assertTrue(c.selected) + self.assertEqual(c.default_reminders, [EmailReminder(60), PopupReminder(15)]) + self.assertEqual(c.notification_types, TEST_NOTIFICATION_TYPES) + self.assertEqual(c.access_role, TEST_ACCESS_ROLE) + self.assertTrue(c.primary) + self.assertFalse(c.deleted) + + self.assertEqual(c.summary, 'Summary') + self.assertEqual(c.calendar_id, 'Calendar id') + self.assertEqual(c.description, 'Description') + self.assertEqual(c.location, 'Fiji') + self.assertEqual(c.timezone, TEST_TIMEZONE) + self.assertListEqual(c.allowed_conference_solution_types, TEST_ALLOWED_CONFERENCE_SOLUTION_TYPES) + + c.color_id = '2' + self.assertEqual(c.color_id, '2') + self.assertIsNone(c.background_color) + self.assertIsNone(c.foreground_color) + + def test_repr_str(self): + c = CalendarListEntry( + calendar_id='Calendar id', + summary_override='Summary override', + _summary='Summary', + ) + self.assertEqual(str(c), 'Summary override - (Summary)') + self.assertEqual(repr(c), '') + + def test_eq(self): + c1 = CalendarListEntry( + calendar_id='Calendar id', + summary_override='Summary override', + _summary='Summary', + ) + c2 = CalendarListEntry( + calendar_id='Calendar id2', + summary_override='Summary override2', + _summary='Summary2', + ) + self.assertEqual(c1, c1) + self.assertNotEqual(c1, c2) + self.assertNotEqual(c1, 'Calendar') + + +class TestCalendarListEntrySerializer(TestCase): + + def test_to_json(self): + c = CalendarListEntry( + summary_override='Summary override', + color_id='1', + background_color='#123123', + foreground_color='#234234', + hidden=False, + selected=True, + default_reminders=[EmailReminder(60), PopupReminder(15)], + notification_types=TEST_NOTIFICATION_TYPES, + _access_role=TEST_ACCESS_ROLE, + _primary=True, + _deleted=False, + + _summary='Summary', + calendar_id='Calendar id', + _description='Description', + _location='Fiji', + _timezone=TEST_TIMEZONE, + _allowed_conference_solution_types=TEST_ALLOWED_CONFERENCE_SOLUTION_TYPES + ) + expected_calendar_json = { + 'id': 'Calendar id', + 'summaryOverride': 'Summary override', + 'colorId': '1', + 'backgroundColor': '#123123', + 'foregroundColor': '#234234', + 'hidden': False, + 'selected': True, + 'defaultReminders': [ + {'method': 'email', 'minutes': 60}, + {'method': 'popup', 'minutes': 15} + ], + 'notificationSettings': { + 'notifications': [ + {'type': 'eventCreation', 'method': 'email'}, + {'type': 'eventChange', 'method': 'email'} + ] + } + } + self.assertDictEqual(CalendarListEntrySerializer.to_json(c), expected_calendar_json) + + c = CalendarListEntry( + summary_override='Summary override', + calendar_id='Calendar id', + _timezone=TEST_TIMEZONE, + ) + expected_calendar_json = { + 'id': 'Calendar id', + 'summaryOverride': 'Summary override', + 'hidden': False, + 'selected': False, + } + self.assertDictEqual(CalendarListEntrySerializer.to_json(c), expected_calendar_json) + + def test_to_object(self): + calendar_json = { + "id": 'Calendar id', + "summary": 'Summary', + "description": 'Description', + "location": 'Fiji', + "timeZone": TEST_TIMEZONE, + "summaryOverride": 'Summary override', + "colorId": '1', + "backgroundColor": '#123123', + "foregroundColor": '#234234', + "hidden": False, + "selected": True, + "accessRole": TEST_ACCESS_ROLE, + "defaultReminders": [ + {"method": 'email', "minutes": 60}, + {"method": 'popup', "minutes": 15} + ], + "notificationSettings": { + "notifications": [ + {'type': 'eventCreation', 'method': 'email'}, + {'type': 'eventChange', 'method': 'email'} + ] + }, + "primary": True, + "deleted": False, + "conferenceProperties": { + "allowedConferenceSolutionTypes": TEST_ALLOWED_CONFERENCE_SOLUTION_TYPES + } + } + + serializer = CalendarListEntrySerializer(calendar_json) + c = serializer.get_object() + + self.assertEqual(c.summary_override, 'Summary override') + self.assertEqual(c.color_id, '1') + self.assertEqual(c.background_color, '#123123') + self.assertEqual(c.foreground_color, '#234234') + self.assertFalse(c.hidden) + self.assertTrue(c.selected) + self.assertListEqual(c.default_reminders, [EmailReminder(60), PopupReminder(15)]) + self.assertEqual(c.notification_types, TEST_NOTIFICATION_TYPES) + self.assertEqual(c.access_role, TEST_ACCESS_ROLE) + self.assertTrue(c.primary) + self.assertFalse(c.deleted) + + self.assertEqual(c.summary, 'Summary') + self.assertEqual(c.calendar_id, 'Calendar id') + self.assertEqual(c.description, 'Description') + self.assertEqual(c.location, 'Fiji') + self.assertEqual(c.timezone, TEST_TIMEZONE) + self.assertListEqual(c.allowed_conference_solution_types, TEST_ALLOWED_CONFERENCE_SOLUTION_TYPES) + + calendar_json = """{ + "id": "Calendar id", + "foregroundColor": "#234234", + "defaultReminders": [ + {"method": "email", "minutes": 60}, + {"method": "popup", "minutes": 15} + ], + "primary": true, + "conferenceProperties": { + "allowedConferenceSolutionTypes": ["eventHangout", "eventNamedHangout"] + } + }""" + + serializer = CalendarListEntrySerializer(calendar_json) + c = serializer.get_object() + + self.assertEqual(c.foreground_color, '#234234') + self.assertListEqual(c.default_reminders, [EmailReminder(60), PopupReminder(15)]) + self.assertTrue(c.primary) + + self.assertEqual(c.calendar_id, 'Calendar id') + self.assertListEqual(c.allowed_conference_solution_types, TEST_ALLOWED_CONFERENCE_SOLUTION_TYPES) diff --git a/google-calendar-simple-api/tests/test_conference.py b/google-calendar-simple-api/tests/test_conference.py new file mode 100644 index 0000000000000000000000000000000000000000..184a6e0a29e2c0fb657fb58e504c32c55a0f93ab --- /dev/null +++ b/google-calendar-simple-api/tests/test_conference.py @@ -0,0 +1,550 @@ +from unittest import TestCase + +from gcsa.conference import EntryPoint, ConferenceSolution, ConferenceSolutionCreateRequest, SolutionType, \ + _BaseConferenceSolution +from gcsa.serializers.conference_serializer import EntryPointSerializer, ConferenceSolutionSerializer, \ + ConferenceSolutionCreateRequestSerializer + + +class TestBaseConferenceSolution(TestCase): + + def test_init(self): + conference_solution = _BaseConferenceSolution( + conference_id='hello', + signature='signature', + notes='important notes' + ) + self.assertEqual(conference_solution.conference_id, 'hello') + self.assertEqual(conference_solution.signature, 'signature') + self.assertEqual(conference_solution.notes, 'important notes') + + def test_notes_length(self): + with self.assertRaises(ValueError): + _BaseConferenceSolution(notes='*' * 2049) + + def test_eq(self): + conference_solution = _BaseConferenceSolution( + conference_id='hello', + signature='signature', + notes='important notes' + ) + self.assertFalse(conference_solution == 5) + self.assertTrue(conference_solution == conference_solution) + + +class TestEntryPoint(TestCase): + def test_init(self): + entry_point = EntryPoint( + EntryPoint.VIDEO, + uri='https://video-conf.com/123123', + label='label', + pin='rU9xzGHz', + access_code='sUhk4QPn', + meeting_code='sUhk4QPn', + passcode='YKa7m4D6', + password='JW7t7f35' + ) + + self.assertEqual(entry_point.entry_point_type, EntryPoint.VIDEO) + self.assertEqual(entry_point.uri, 'https://video-conf.com/123123') + self.assertEqual(entry_point.label, 'label') + self.assertEqual(entry_point.pin, 'rU9xzGHz') + self.assertEqual(entry_point.access_code, 'sUhk4QPn') + self.assertEqual(entry_point.meeting_code, 'sUhk4QPn') + self.assertEqual(entry_point.passcode, 'YKa7m4D6') + self.assertEqual(entry_point.password, 'JW7t7f35') + + def test_checks(self): + with self.assertRaises(ValueError): + EntryPoint('Offline') + with self.assertRaises(ValueError): + EntryPoint(EntryPoint.PHONE, label='a' * 513) + with self.assertRaises(ValueError): + EntryPoint(EntryPoint.PHONE, pin='a' * 129) + with self.assertRaises(ValueError): + EntryPoint(EntryPoint.PHONE, access_code='a' * 129) + with self.assertRaises(ValueError): + EntryPoint(EntryPoint.PHONE, meeting_code='a' * 129) + with self.assertRaises(ValueError): + EntryPoint(EntryPoint.PHONE, passcode='a' * 129) + with self.assertRaises(ValueError): + EntryPoint(EntryPoint.PHONE, password='a' * 129) + + def test_eq(self): + entry_point = EntryPoint( + EntryPoint.VIDEO, + uri='https://video-conf.com/123123', + label='label', + pin='rU9xzGHz', + access_code='sUhk4QPn', + meeting_code='sUhk4QPn', + passcode='YKa7m4D6', + password='JW7t7f35' + ) + self.assertFalse(entry_point == 5) + self.assertTrue(entry_point == entry_point) + + def test_repr_str(self): + entry_point = EntryPoint( + EntryPoint.VIDEO, + uri='https://video-conf.com/123123', + label='label', + pin='rU9xzGHz', + access_code='sUhk4QPn', + meeting_code='sUhk4QPn', + passcode='YKa7m4D6', + password='JW7t7f35' + ) + self.assertEqual(entry_point.__repr__(), "") + self.assertEqual(entry_point.__str__(), "video - 'https://video-conf.com/123123'") + + +class TestEntryPointSerializer(TestCase): + def test_to_json(self): + entry_point = EntryPoint( + EntryPoint.SIP, + uri='sip:123123', + label='label', + pin='rU9xzGHz', + access_code='sUhk4QPn', + meeting_code='sUhk4QPn', + passcode='YKa7m4D6', + password='JW7t7f35' + ) + expected = { + 'entryPointType': 'sip', + 'uri': 'sip:123123', + 'label': 'label', + 'pin': 'rU9xzGHz', + 'accessCode': 'sUhk4QPn', + 'meetingCode': 'sUhk4QPn', + 'passcode': 'YKa7m4D6', + 'password': 'JW7t7f35' + } + + serializer = EntryPointSerializer(entry_point) + self.assertDictEqual(serializer.get_json(), expected) + + entry_point = EntryPoint( + EntryPoint.MORE, + uri='https://less.com', + pin='rU9xzGHz', + meeting_code='sUhk4QPn', + password='JW7t7f35' + ) + + expected = { + 'entryPointType': 'more', + 'uri': 'https://less.com', + 'pin': 'rU9xzGHz', + 'meetingCode': 'sUhk4QPn', + 'password': 'JW7t7f35' + } + + self.assertDictEqual(EntryPointSerializer.to_json(entry_point), expected) + + def test_to_object(self): + entry_point_json = { + 'entryPointType': 'sip', + 'uri': 'sip:123123', + 'label': 'label', + 'pin': 'rU9xzGHz', + 'accessCode': 'sUhk4QPn', + 'meetingCode': 'sUhk4QPn', + 'passcode': 'YKa7m4D6', + 'password': 'JW7t7f35' + } + entry_point = EntryPointSerializer.to_object(entry_point_json) + self.assertEqual(entry_point.entry_point_type, EntryPoint.SIP) + self.assertEqual(entry_point.uri, 'sip:123123') + self.assertEqual(entry_point.label, 'label') + self.assertEqual(entry_point.pin, 'rU9xzGHz') + self.assertEqual(entry_point.access_code, 'sUhk4QPn') + self.assertEqual(entry_point.meeting_code, 'sUhk4QPn') + self.assertEqual(entry_point.passcode, 'YKa7m4D6') + self.assertEqual(entry_point.password, 'JW7t7f35') + + entry_point_json = { + 'entryPointType': 'more', + 'uri': 'https://less.com', + 'password': 'JW7t7f35' + } + entry_point = EntryPointSerializer.to_object(entry_point_json) + self.assertEqual(entry_point.entry_point_type, EntryPoint.MORE) + self.assertEqual(entry_point.uri, 'https://less.com') + self.assertIsNone(entry_point.label) + self.assertIsNone(entry_point.pin) + self.assertIsNone(entry_point.access_code) + self.assertIsNone(entry_point.meeting_code) + self.assertIsNone(entry_point.passcode) + self.assertEqual(entry_point.password, 'JW7t7f35') + + +class TestConferenceSolution(TestCase): + def test_init(self): + conference_solution = ConferenceSolution( + entry_points=EntryPoint(EntryPoint.VIDEO), + solution_type=SolutionType.HANGOUTS_MEET, + name='Hangout', + icon_uri='https://icon.com', + conference_id='aaa-bbbb-ccc', + signature='abc4efg12345', + notes='important notes' + ) + + self.assertListEqual(conference_solution.entry_points, [EntryPoint(EntryPoint.VIDEO)]) + self.assertEqual(conference_solution.solution_type, SolutionType.HANGOUTS_MEET) + self.assertEqual(conference_solution.name, 'Hangout') + self.assertEqual(conference_solution.icon_uri, 'https://icon.com') + self.assertEqual(conference_solution.conference_id, 'aaa-bbbb-ccc') + self.assertEqual(conference_solution.signature, 'abc4efg12345') + self.assertEqual(conference_solution.notes, 'important notes') + + conference_solution = ConferenceSolution( + entry_points=[EntryPoint(EntryPoint.VIDEO)], + ) + self.assertEqual(conference_solution.entry_points, [EntryPoint(EntryPoint.VIDEO)]) + + conference_solution = ConferenceSolution( + entry_points=[EntryPoint(EntryPoint.VIDEO), + EntryPoint(EntryPoint.PHONE)], + ) + self.assertEqual(conference_solution.entry_points, [EntryPoint(EntryPoint.VIDEO), + EntryPoint(EntryPoint.PHONE)]) + + def test_entry_point_types(self): + with self.assertRaises(ValueError): + ConferenceSolution( + entry_points=[] + ) + with self.assertRaises(ValueError): + ConferenceSolution( + entry_points=[ + EntryPoint(EntryPoint.VIDEO), + EntryPoint(EntryPoint.VIDEO) + ] + ) + with self.assertRaises(ValueError): + ConferenceSolution( + entry_points=[ + EntryPoint(EntryPoint.SIP), + EntryPoint(EntryPoint.SIP) + ] + ) + with self.assertRaises(ValueError): + ConferenceSolution( + entry_points=[ + EntryPoint(EntryPoint.SIP), + EntryPoint(EntryPoint.MORE), + EntryPoint(EntryPoint.MORE) + ] + ) + with self.assertRaises(ValueError): + # can't have only MORE entry point(s) + ConferenceSolution( + entry_points=[ + EntryPoint(EntryPoint.MORE) + ] + ) + + def test_eq(self): + conference_solution = ConferenceSolution( + entry_points=EntryPoint(EntryPoint.VIDEO), + solution_type=SolutionType.HANGOUTS_MEET, + name='Hangout', + icon_uri='https://icon.com', + conference_id='aaa-bbbb-ccc', + signature='abc4efg12345', + notes='important notes' + ) + self.assertFalse(conference_solution == 5) + self.assertTrue(conference_solution == conference_solution) + + def test_repr_str(self): + conference_solution = ConferenceSolution( + entry_points=EntryPoint(EntryPoint.VIDEO), + solution_type=SolutionType.HANGOUTS_MEET, + name='Hangout', + icon_uri='https://icon.com', + conference_id='aaa-bbbb-ccc', + signature='abc4efg12345', + notes='important notes' + ) + + self.assertEqual(conference_solution.__repr__(), + "]>") + self.assertEqual(conference_solution.__str__(), + "hangoutsMeet - []") + + conference_solution = ConferenceSolution( + entry_points=[EntryPoint(EntryPoint.VIDEO), EntryPoint(EntryPoint.SIP)], + solution_type=SolutionType.HANGOUTS_MEET, + name='Hangout', + icon_uri='https://icon.com', + conference_id='aaa-bbbb-ccc', + signature='abc4efg12345', + notes='important notes' + ) + + self.assertEqual(conference_solution.__repr__(), + ", ]>") + self.assertEqual(conference_solution.__str__(), + "hangoutsMeet - [, ]") + + +class TestConferenceSolutionSerializer(TestCase): + def test_to_json(self): + conference_solution = ConferenceSolution( + entry_points=EntryPoint(EntryPoint.VIDEO, uri='https://video.com'), + solution_type=SolutionType.HANGOUTS_MEET, + name='Hangout', + icon_uri='https://icon.com', + conference_id='aaa-bbbb-ccc', + signature='abc4efg12345', + notes='important notes' + ) + + expected = { + 'entryPoints': [ + { + 'entryPointType': 'video', + 'uri': 'https://video.com', + } + ], + 'conferenceSolution': { + 'key': { + 'type': 'hangoutsMeet' + }, + 'name': 'Hangout', + 'iconUri': 'https://icon.com' + }, + 'conferenceId': 'aaa-bbbb-ccc', + 'signature': 'abc4efg12345', + 'notes': 'important notes' + } + + serializer = ConferenceSolutionSerializer(conference_solution) + self.assertDictEqual(serializer.get_json(), expected) + + conference_solution = ConferenceSolution( + entry_points=[ + EntryPoint(EntryPoint.VIDEO, uri='https://video.com'), + EntryPoint(EntryPoint.PHONE, uri='+420000000000') + ], + solution_type=SolutionType.NAMED_HANGOUT, + ) + + expected = { + 'entryPoints': [ + { + 'entryPointType': 'video', + 'uri': 'https://video.com', + }, + { + 'entryPointType': 'phone', + 'uri': '+420000000000', + } + ], + 'conferenceSolution': { + 'key': { + 'type': 'eventNamedHangout' + } + } + } + + self.assertDictEqual(ConferenceSolutionSerializer.to_json(conference_solution), expected) + + def test_to_object(self): + conference_solution_json = { + 'entryPoints': [ + { + 'entryPointType': 'video', + 'uri': 'https://video.com', + } + ], + 'conferenceSolution': { + 'key': { + 'type': 'hangoutsMeet' + }, + 'name': 'Hangout', + 'iconUri': 'https://icon.com' + }, + 'conferenceId': 'aaa-bbbb-ccc', + 'signature': 'abc4efg12345', + 'notes': 'important notes' + } + expected_conference_solution = ConferenceSolution( + entry_points=EntryPoint(EntryPoint.VIDEO, uri='https://video.com'), + solution_type=SolutionType.HANGOUTS_MEET, + name='Hangout', + icon_uri='https://icon.com', + conference_id='aaa-bbbb-ccc', + signature='abc4efg12345', + notes='important notes' + ) + self.assertEqual(ConferenceSolutionSerializer.to_object(conference_solution_json), + expected_conference_solution) + + conference_solution_json = { + 'entryPoints': [ + { + 'entryPointType': 'video', + 'uri': 'https://video.com', + }, + { + 'entryPointType': 'phone', + 'uri': '+420000000000', + } + ], + 'conferenceSolution': { + 'key': { + 'type': 'eventNamedHangout' + } + } + } + expected_conference_solution = ConferenceSolution( + entry_points=[ + EntryPoint(EntryPoint.VIDEO, uri='https://video.com'), + EntryPoint(EntryPoint.PHONE, uri='+420000000000') + ], + solution_type=SolutionType.NAMED_HANGOUT, + ) + self.assertEqual(ConferenceSolutionSerializer.to_object(conference_solution_json), + expected_conference_solution) + + def test_eq(self): + conference_solution = ConferenceSolution( + entry_points=EntryPoint(EntryPoint.VIDEO, uri='https://video.com'), + solution_type=SolutionType.HANGOUTS_MEET, + name='Hangout', + icon_uri='https://icon.com', + conference_id='aaa-bbbb-ccc', + signature='abc4efg12345', + notes='important notes' + ) + self.assertFalse(conference_solution == 5) + + +class TestConferenceSolutionCreateRequest(TestCase): + def test_init(self): + cscr = ConferenceSolutionCreateRequest( + solution_type=SolutionType.HANGOUTS_MEET, + request_id='hello1234', + conference_id='conference-id', + signature='signature', + notes='important notes' + ) + self.assertEqual(cscr.solution_type, 'hangoutsMeet') + self.assertEqual(cscr.request_id, 'hello1234') + self.assertEqual(cscr.conference_id, 'conference-id') + self.assertEqual(cscr.signature, 'signature') + self.assertEqual(cscr.notes, 'important notes') + + cscr = ConferenceSolutionCreateRequest( + solution_type=SolutionType.HANGOUTS_MEET, + ) + self.assertEqual(cscr.solution_type, 'hangoutsMeet') + self.assertIsNotNone(cscr.request_id) + + def test_eq(self): + cscr = ConferenceSolutionCreateRequest( + solution_type=SolutionType.HANGOUTS_MEET, + request_id='hello1234', + conference_id='conference-id', + signature='signature', + notes='important notes' + ) + self.assertFalse(cscr == 5) + self.assertTrue(cscr == cscr) + + def test_repr_str(self): + cscr = ConferenceSolutionCreateRequest( + solution_type=SolutionType.HANGOUTS_MEET, + request_id='hello1234', + conference_id='conference-id', + signature='signature', + notes='important notes' + ) + + self.assertEqual(cscr.__repr__(), "") + self.assertEqual(cscr.__str__(), "hangoutsMeet - status:'None'") + + +class TestConferenceSolutionCreateRequestSerializer(TestCase): + def test_to_json(self): + cscr = ConferenceSolutionCreateRequest( + solution_type=SolutionType.HANGOUTS_MEET, + request_id='hello1234', + conference_id='conference-id', + signature='signature', + notes='important notes', + _status='pending' + ) + expected = { + 'createRequest': { + 'requestId': 'hello1234', + 'conferenceSolutionKey': { + 'type': 'hangoutsMeet' + }, + 'status': { + 'statusCode': 'pending' + } + }, + 'conferenceId': 'conference-id', + 'signature': 'signature', + 'notes': 'important notes' + } + serializer = ConferenceSolutionCreateRequestSerializer(cscr) + self.assertDictEqual(serializer.get_json(), expected) + + cscr = ConferenceSolutionCreateRequest( + solution_type=SolutionType.HANGOUTS_MEET, + notes='important notes' + ) + expected = { + 'createRequest': { + 'requestId': cscr.request_id, + 'conferenceSolutionKey': { + 'type': 'hangoutsMeet' + } + }, + 'notes': 'important notes' + } + self.assertDictEqual(ConferenceSolutionCreateRequestSerializer.to_json(cscr), expected) + + def test_to_object(self): + cscr_json = { + 'createRequest': { + 'requestId': 'hello1234', + 'conferenceSolutionKey': { + 'type': 'hangoutsMeet' + } + }, + 'conferenceId': 'conference-id', + 'signature': 'signature', + 'notes': 'important notes' + } + expected_cscr = ConferenceSolutionCreateRequest( + solution_type=SolutionType.HANGOUTS_MEET, + request_id='hello1234', + conference_id='conference-id', + signature='signature', + notes='important notes' + ) + self.assertEqual(ConferenceSolutionCreateRequestSerializer.to_object(cscr_json), expected_cscr) + + cscr_json = { + 'createRequest': { + 'requestId': 'hello1234', + 'conferenceSolutionKey': { + 'type': 'hangoutsMeet' + } + }, + 'signature': 'signature' + } + expected_cscr = ConferenceSolutionCreateRequest( + solution_type=SolutionType.HANGOUTS_MEET, + request_id='hello1234', + signature='signature' + ) + self.assertEqual(ConferenceSolutionCreateRequestSerializer.to_object(cscr_json), expected_cscr) diff --git a/google-calendar-simple-api/tests/test_event.py b/google-calendar-simple-api/tests/test_event.py new file mode 100644 index 0000000000000000000000000000000000000000..4759fc015086ea6d5cfb3191ca74d4c9bb5358f1 --- /dev/null +++ b/google-calendar-simple-api/tests/test_event.py @@ -0,0 +1,764 @@ +from datetime import time +from unittest import TestCase +from beautiful_date import Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sept, Oct, Dec, hours, days, Nov + +from gcsa.attachment import Attachment +from gcsa.attendee import Attendee, ResponseStatus +from gcsa.conference import ConferenceSolution, EntryPoint, SolutionType, ConferenceSolutionCreateRequest +from gcsa.event import Event, Visibility +from gcsa.recurrence import Recurrence, DAILY, SU, SA, MONDAY, WEEKLY +from gcsa.reminders import PopupReminder, EmailReminder +from gcsa.serializers.event_serializer import EventSerializer +from gcsa.util.date_time_util import ensure_localisation + +TEST_TIMEZONE = 'Pacific/Fiji' + + +class TestEvent(TestCase): + def test_init(self): + event = Event( + 'Breakfast', + event_id='123', + start=(1 / Feb / 2019)[9:00], + end=(31 / Dec / 2019)[23:59], + _created=ensure_localisation((20 / Nov / 2020)[16:19], TEST_TIMEZONE), + _updated=ensure_localisation((25 / Nov / 2020)[16:19], TEST_TIMEZONE), + timezone=TEST_TIMEZONE, + description='Everyday breakfast', + location='Home', + guests_can_invite_others=False, + guests_can_modify=True, + guests_can_see_other_guests=False, + recurrence=[ + Recurrence.rule(freq=DAILY), + Recurrence.exclude_rule(by_week_day=[SU, SA]), + Recurrence.exclude_dates([ + 19 / Apr / 2019, + 22 / Apr / 2019, + 12 / May / 2019 + ]) + ], + visibility=Visibility.PRIVATE, + minutes_before_popup_reminder=15 + ) + + self.assertEqual(event.summary, 'Breakfast') + self.assertEqual(event.id, '123') + self.assertEqual(event.start, ensure_localisation((1 / Feb / 2019)[9:00], TEST_TIMEZONE)) + self.assertEqual(event.end, ensure_localisation((31 / Dec / 2019)[23:59], TEST_TIMEZONE)) + self.assertEqual(event.created, ensure_localisation((20 / Nov / 2020)[16:19], TEST_TIMEZONE)) + self.assertEqual(event.updated, ensure_localisation((25 / Nov / 2020)[16:19], TEST_TIMEZONE)) + self.assertEqual(event.description, 'Everyday breakfast') + self.assertEqual(event.location, 'Home') + self.assertEqual(len(event.recurrence), 3) + self.assertEqual(event.visibility, Visibility.PRIVATE) + self.assertIsInstance(event.reminders[0], PopupReminder) + self.assertEqual(event.reminders[0].minutes_before_start, 15) + self.assertFalse(event.guests_can_invite_others) + self.assertTrue(event.guests_can_modify) + self.assertFalse(event.guests_can_see_other_guests) + + def test_init_no_end(self): + start = 1 / Jun / 2019 + event = Event('Good day', start, timezone=TEST_TIMEZONE) + self.assertEqual(event.end, start + 1 * days) + + start = ensure_localisation((1 / Jul / 2019)[12:00], TEST_TIMEZONE) + event = Event('Lunch', start, timezone=TEST_TIMEZONE) + self.assertEqual(event.end, start + 1 * hours) + + def test_init_no_start_or_end(self): + event = Event('Good day', start=None, timezone=TEST_TIMEZONE) + self.assertIsNone(event.start) + self.assertIsNone(event.end) + + def test_init_different_date_types(self): + with self.assertRaises(TypeError): + Event('Good day', start=(1 / Jan / 2019), end=(2 / Jan / 2019)[5:55], timezone=TEST_TIMEZONE) + + def test_add_attachment(self): + e = Event('Good day', start=(1 / Aug / 2019), timezone=TEST_TIMEZONE) + e.add_attachment('https://file.url', 'My file', "application/vnd.google-apps.document") + + self.assertIsInstance(e.attachments[0], Attachment) + self.assertEqual(e.attachments[0].title, 'My file') + + def test_add_reminders(self): + e = Event('Good day', start=(28 / Mar / 2019), timezone=TEST_TIMEZONE) + + self.assertEqual(len(e.reminders), 0) + + e.add_email_reminder(35) + self.assertEqual(len(e.reminders), 1) + self.assertIsInstance(e.reminders[0], EmailReminder) + self.assertEqual(e.reminders[0].minutes_before_start, 35) + + e.add_popup_reminder(41) + self.assertEqual(len(e.reminders), 2) + self.assertIsInstance(e.reminders[1], PopupReminder) + self.assertEqual(e.reminders[1].minutes_before_start, 41) + + e.add_popup_reminder(days_before=1, at=time(12, 0)) + self.assertEqual(len(e.reminders), 3) + self.assertIsInstance(e.reminders[2], PopupReminder) + self.assertEqual(e.reminders[2].days_before, 1) + self.assertEqual(e.reminders[2].at, time(12, 0)) + + e.add_email_reminder(days_before=1, at=time(13, 30)) + self.assertEqual(len(e.reminders), 4) + self.assertIsInstance(e.reminders[3], EmailReminder) + self.assertEqual(e.reminders[3].days_before, 1) + self.assertEqual(e.reminders[3].at, time(13, 30)) + + def test_add_attendees(self): + e = Event('Good day', + start=(17 / Jul / 2020), + timezone=TEST_TIMEZONE, + attendees=[ + Attendee(email="attendee@gmail.com"), + "attendee2@gmail.com", + ]) + + self.assertEqual(len(e.attendees), 2) + e.add_attendee(Attendee("attendee3@gmail.com")) + e.add_attendee(Attendee(email="attendee4@gmail.com")) + e.add_attendees([ + Attendee(email="attendee5@gmail.com"), + "attendee6@gmail.com" + ]) + self.assertEqual(len(e.attendees), 6) + + self.assertEqual(e.attendees[0].email, "attendee@gmail.com") + self.assertEqual(e.attendees[1].email, "attendee2@gmail.com") + self.assertEqual(e.attendees[2].email, "attendee3@gmail.com") + self.assertEqual(e.attendees[3].email, "attendee4@gmail.com") + self.assertEqual(e.attendees[4].email, "attendee5@gmail.com") + self.assertEqual(e.attendees[5].email, "attendee6@gmail.com") + + def test_reminders_checks(self): + with self.assertRaises(ValueError): + Event('Too many reminders', + start=20 / Jul / 2020, + reminders=[EmailReminder()] * 6) + + with self.assertRaises(ValueError): + Event('Default and overrides together', + start=20 / Jul / 2020, + reminders=EmailReminder(), + default_reminders=True) + + e = Event('Almost too many reminders', + start=20 / Jul / 2020, + reminders=[EmailReminder()] * 5) + with self.assertRaises(ValueError): + e.add_email_reminder() + + def test_repr_str(self): + e = Event('Good event', + start=20 / Jul / 2020) + self.assertEqual(str(e), '2020-07-20 - Good event') + + self.assertEqual(repr(e), '') + + def test_equal(self): + dp = { + 'summary': 'Breakfast', + 'start': (1 / Feb / 2019)[9:00] + } + + attachments_dp = { + "file_url": 'https://file.com', + "mime_type": "application/vnd.google-apps.map" + } + + event1 = Event( + **dp, + event_id='123', + end=(31 / Dec / 2019)[23:59], + timezone=TEST_TIMEZONE, + description='Everyday breakfast', + location='Home', + recurrence=Recurrence.rule(freq=DAILY), + color_id='1', + visibility=Visibility.PRIVATE, + attendees='mail@gmail.com', + attachments=Attachment(title='My doc', **attachments_dp), + minutes_before_popup_reminder=15, + other={"key": "value"} + ) + + self.assertEqual(event1, event1) + self.assertNotEqual(Event(**dp), Event('Breakfast', start=(22 / Jun / 2020)[22:22])) + + self.assertNotEqual(Event(**dp, event_id='123'), + Event(**dp, event_id='abc')) + + self.assertNotEqual(Event(**dp, description='Desc1'), + Event(**dp, description='Desc2')) + + self.assertNotEqual(Event(**dp, location='Home'), + Event(**dp, location='Work')) + + self.assertNotEqual(Event(**dp, recurrence=Recurrence.rule(freq=DAILY)), + Event(**dp, recurrence=Recurrence.rule(freq=WEEKLY))) + + self.assertNotEqual(Event(**dp, color_id='1'), + Event(**dp, color_id='2')) + + self.assertNotEqual(Event(**dp, visibility=Visibility.PRIVATE), + Event(**dp, visibility=Visibility.PUBLIC)) + + self.assertNotEqual(Event(**dp, attendees='mail1@gmail.com'), + Event(**dp, attendees='mail2@gmail.com')) + + self.assertNotEqual(Event(**dp, attachments=Attachment(title='Attachment1', **attachments_dp)), + Event(**dp, attachments=Attachment(title='Attachment2', **attachments_dp))) + + self.assertNotEqual(Event(**dp, minutes_before_email_reminder=10), + Event(**dp, minutes_before_popup_reminder=10)) + + self.assertNotEqual(Event(**dp, other={"key1": "value1"}), + Event(**dp, other={"key2": "value2"})) + + def test_ordering(self): + e1 = Event('Good day', start=(28 / Sept / 2020), end=(30 / Sept / 2020), timezone=TEST_TIMEZONE) + e2 = Event('Good day', start=(28 / Sept / 2020), end=(16 / Oct / 2020), timezone=TEST_TIMEZONE) + e3 = Event('Good day', start=(29 / Sept / 2020), end=(30 / Sept / 2020), timezone=TEST_TIMEZONE) + e4 = Event('Good day', start=(29 / Sept / 2020)[22:22], end=(30 / Sept / 2020)[15:15], timezone=TEST_TIMEZONE) + e5 = Event('Good day', start=(29 / Sept / 2020)[22:22], end=(30 / Sept / 2020)[18:15], timezone=TEST_TIMEZONE) + e6 = Event('Good day', start=(29 / Sept / 2020)[23:22], end=(30 / Sept / 2020)[18:15], timezone=TEST_TIMEZONE) + + self.assertEqual(list(sorted([e5, e6, e1, e3, e2, e4])), [e1, e2, e3, e4, e5, e6]) + + self.assertTrue(e1 < e2) + self.assertTrue(e3 > e2) + self.assertTrue(e5 >= e2) + self.assertTrue(e2 >= e2) + self.assertTrue(e5 <= e5) + self.assertTrue(e5 <= e6) + + +class TestEventSerializer(TestCase): + def setUp(self): + self.maxDiff = None + + def test_to_json(self): + e = Event('Good day', start=(28 / Sept / 2019), timezone=TEST_TIMEZONE) + expected_event_json = { + 'summary': 'Good day', + 'start': {'date': '2019-09-28'}, + 'end': {'date': '2019-09-29'}, + 'recurrence': [], + 'visibility': 'default', + 'attendees': [], + 'reminders': {'useDefault': False}, + 'attachments': [], + 'guestsCanInviteOthers': True, + 'guestsCanModify': False, + 'guestsCanSeeOtherGuests': True, + } + self.assertDictEqual(EventSerializer.to_json(e), expected_event_json) + + e = Event('Good day', start=(28 / Oct / 2019)[11:22:33], timezone=TEST_TIMEZONE) + expected_event_json = { + 'summary': 'Good day', + 'start': {'dateTime': '2019-10-28T11:22:33+12:00', 'timeZone': TEST_TIMEZONE}, + 'end': {'dateTime': '2019-10-28T12:22:33+12:00', 'timeZone': TEST_TIMEZONE}, + 'recurrence': [], + 'visibility': 'default', + 'attendees': [], + 'reminders': {'useDefault': False}, + 'attachments': [], + 'guestsCanInviteOthers': True, + 'guestsCanModify': False, + 'guestsCanSeeOtherGuests': True, + } + self.assertDictEqual(EventSerializer.to_json(e), expected_event_json) + + def test_to_json_recurrence(self): + e = Event('Good day', + start=(1 / Jan / 2019)[11:22:33], + end=(1 / Jan / 2020)[11:22:33], + timezone=TEST_TIMEZONE, + recurrence=[ + Recurrence.rule(freq=DAILY), + Recurrence.exclude_rule(by_week_day=MONDAY), + Recurrence.exclude_dates([ + 19 / Apr / 2019, + 22 / Apr / 2019, + 12 / May / 2019 + ]) + ]) + expected_event_json = { + 'summary': 'Good day', + 'start': {'dateTime': '2019-01-01T11:22:33+13:00', 'timeZone': TEST_TIMEZONE}, + 'end': {'dateTime': '2020-01-01T11:22:33+13:00', 'timeZone': TEST_TIMEZONE}, + 'recurrence': [ + 'RRULE:FREQ=DAILY;WKST=SU', + 'EXRULE:FREQ=DAILY;BYDAY=MO;WKST=SU', + 'EXDATE;VALUE=DATE:20190419,20190422,20190512' + ], + 'visibility': 'default', + 'attendees': [], + 'reminders': {'useDefault': False}, + 'attachments': [], + 'guestsCanInviteOthers': True, + 'guestsCanModify': False, + 'guestsCanSeeOtherGuests': True, + } + self.assertDictEqual(EventSerializer.to_json(e), expected_event_json) + + def test_to_json_attachments(self): + e = Event('Good day', + start=(1 / Jan / 2019)[11:22:33], + timezone=TEST_TIMEZONE, + attachments=[ + Attachment('https://file.url1', 'My file1', "application/vnd.google-apps.document"), + Attachment('https://file.url2', 'My file2', "application/vnd.google-apps.document") + ]) + expected_event_json = { + 'summary': 'Good day', + 'start': {'dateTime': '2019-01-01T11:22:33+13:00', 'timeZone': TEST_TIMEZONE}, + 'end': {'dateTime': '2019-01-01T12:22:33+13:00', 'timeZone': TEST_TIMEZONE}, + 'recurrence': [], + 'visibility': 'default', + 'attendees': [], + 'reminders': {'useDefault': False}, + 'attachments': [ + { + 'title': 'My file1', + 'fileUrl': 'https://file.url1', + 'mimeType': 'application/vnd.google-apps.document' + }, + { + 'title': 'My file2', + 'fileUrl': 'https://file.url2', + 'mimeType': 'application/vnd.google-apps.document' + } + ], + 'guestsCanInviteOthers': True, + 'guestsCanModify': False, + 'guestsCanSeeOtherGuests': True, + } + self.assertDictEqual(EventSerializer.to_json(e), expected_event_json) + + def test_to_json_reminders(self): + e = Event('Good day', + start=(1 / Jan / 2019)[11:22:33], + timezone=TEST_TIMEZONE, + minutes_before_popup_reminder=30, + minutes_before_email_reminder=120) + expected_event_json = { + 'summary': 'Good day', + 'start': {'dateTime': '2019-01-01T11:22:33+13:00', 'timeZone': TEST_TIMEZONE}, + 'end': {'dateTime': '2019-01-01T12:22:33+13:00', 'timeZone': TEST_TIMEZONE}, + 'recurrence': [], + 'visibility': 'default', + 'attendees': [], + 'reminders': { + 'overrides': [ + {'method': 'popup', 'minutes': 30}, + {'method': 'email', 'minutes': 120} + ], + 'useDefault': False + }, + 'attachments': [], + 'guestsCanInviteOthers': True, + 'guestsCanModify': False, + 'guestsCanSeeOtherGuests': True, + } + self.assertDictEqual(EventSerializer.to_json(e), expected_event_json) + + e = Event('Good day', + start=(1 / Jan / 2019)[11:22:33], + timezone=TEST_TIMEZONE, + reminders=[ + PopupReminder(35), + EmailReminder(45), + PopupReminder(days_before=3, at=time(12, 30)), + EmailReminder(days_before=2, at=time(11, 25)), + ]) + expected_event_json = { + 'summary': 'Good day', + 'start': {'dateTime': '2019-01-01T11:22:33+13:00', 'timeZone': TEST_TIMEZONE}, + 'end': {'dateTime': '2019-01-01T12:22:33+13:00', 'timeZone': TEST_TIMEZONE}, + 'recurrence': [], + 'visibility': 'default', + 'attendees': [], + 'reminders': { + 'overrides': [ + {'method': 'popup', 'minutes': 35}, + {'method': 'email', 'minutes': 45}, + {'method': 'popup', 'minutes': (11 * 60 + 22) + (2 * 24 * 60 + 11 * 60 + 30)}, + {'method': 'email', 'minutes': (11 * 60 + 22) + (1 * 24 * 60 + 12 * 60 + 35)}, + ], + 'useDefault': False + }, + 'attachments': [], + 'guestsCanInviteOthers': True, + 'guestsCanModify': False, + 'guestsCanSeeOtherGuests': True, + } + self.assertDictEqual(EventSerializer.to_json(e), expected_event_json) + + def test_to_json_attendees(self): + e = Event('Good day', + start=(1 / Jul / 2020)[11:22:33], + timezone=TEST_TIMEZONE, + attendees=[ + Attendee(email='attendee@gmail.com', _response_status=ResponseStatus.NEEDS_ACTION), + Attendee(email='attendee2@gmail.com', _response_status=ResponseStatus.ACCEPTED), + ]) + expected_event_json = { + 'summary': 'Good day', + 'start': {'dateTime': '2020-07-01T11:22:33+12:00', 'timeZone': TEST_TIMEZONE}, + 'end': {'dateTime': '2020-07-01T12:22:33+12:00', 'timeZone': TEST_TIMEZONE}, + 'recurrence': [], + 'visibility': 'default', + 'attendees': [ + {'email': 'attendee@gmail.com', 'responseStatus': ResponseStatus.NEEDS_ACTION}, + {'email': 'attendee2@gmail.com', 'responseStatus': ResponseStatus.ACCEPTED}, + ], + 'reminders': {'useDefault': False}, + 'attachments': [], + 'guestsCanInviteOthers': True, + 'guestsCanModify': False, + 'guestsCanSeeOtherGuests': True, + } + self.assertDictEqual(EventSerializer.to_json(e), expected_event_json) + + e = Event('Good day2', + start=20 / Jul / 2020, + default_reminders=True) + expected_event_json = { + 'summary': 'Good day2', + 'start': {'date': '2020-07-20'}, + 'end': {'date': '2020-07-21'}, + 'recurrence': [], + 'visibility': 'default', + 'attendees': [], + 'reminders': {'useDefault': True}, + 'attachments': [], + 'guestsCanInviteOthers': True, + 'guestsCanModify': False, + 'guestsCanSeeOtherGuests': True, + } + self.assertDictEqual(EventSerializer.to_json(e), expected_event_json) + + def test_to_json_conference_solution(self): + e = Event( + 'Good day', + start=(1 / Jul / 2020)[11:22:33], + timezone=TEST_TIMEZONE, + conference_solution=ConferenceSolution( + entry_points=EntryPoint(EntryPoint.VIDEO, uri='https://video.com'), + solution_type=SolutionType.HANGOUTS_MEET, + name='Hangout', + icon_uri='https://icon.com', + conference_id='aaa-bbbb-ccc', + signature='abc4efg12345', + notes='important notes' + ) + ) + expected_event_json = { + 'summary': 'Good day', + 'start': {'dateTime': '2020-07-01T11:22:33+12:00', 'timeZone': TEST_TIMEZONE}, + 'end': {'dateTime': '2020-07-01T12:22:33+12:00', 'timeZone': TEST_TIMEZONE}, + 'recurrence': [], + 'visibility': 'default', + 'attendees': [], + 'reminders': {'useDefault': False}, + 'attachments': [], + 'conferenceData': { + 'entryPoints': [ + { + 'entryPointType': 'video', + 'uri': 'https://video.com', + } + ], + 'conferenceSolution': { + 'key': { + 'type': 'hangoutsMeet' + }, + 'name': 'Hangout', + 'iconUri': 'https://icon.com' + }, + 'conferenceId': 'aaa-bbbb-ccc', + 'signature': 'abc4efg12345', + 'notes': 'important notes' + }, + 'guestsCanInviteOthers': True, + 'guestsCanModify': False, + 'guestsCanSeeOtherGuests': True, + } + self.assertDictEqual(EventSerializer.to_json(e), expected_event_json) + + def test_to_json_conference_solution_create_request(self): + e = Event( + 'Good day', + start=(1 / Jul / 2020)[11:22:33], + timezone=TEST_TIMEZONE, + conference_solution=ConferenceSolutionCreateRequest( + solution_type=SolutionType.HANGOUTS_MEET, + request_id='hello1234', + conference_id='conference-id', + signature='signature', + notes='important notes', + _status='pending' + ) + ) + expected_event_json = { + 'summary': 'Good day', + 'start': {'dateTime': '2020-07-01T11:22:33+12:00', 'timeZone': TEST_TIMEZONE}, + 'end': {'dateTime': '2020-07-01T12:22:33+12:00', 'timeZone': TEST_TIMEZONE}, + 'recurrence': [], + 'visibility': 'default', + 'attendees': [], + 'reminders': {'useDefault': False}, + 'attachments': [], + 'conferenceData': { + 'createRequest': { + 'requestId': 'hello1234', + 'conferenceSolutionKey': { + 'type': 'hangoutsMeet' + }, + 'status': { + 'statusCode': 'pending' + } + }, + 'conferenceId': 'conference-id', + 'signature': 'signature', + 'notes': 'important notes' + }, + 'guestsCanInviteOthers': True, + 'guestsCanModify': False, + 'guestsCanSeeOtherGuests': True, + } + self.assertDictEqual(EventSerializer.to_json(e), expected_event_json) + + def test_to_json_updated(self): + e = Event( + 'Good day', + start=(1 / Jul / 2020)[11:22:33], + timezone=TEST_TIMEZONE, + _updated=ensure_localisation((25 / Nov / 2020)[11:22:33], timezone=TEST_TIMEZONE) + ) + expected_event_json = { + 'summary': 'Good day', + 'start': {'dateTime': '2020-07-01T11:22:33+12:00', 'timeZone': TEST_TIMEZONE}, + 'end': {'dateTime': '2020-07-01T12:22:33+12:00', 'timeZone': TEST_TIMEZONE}, + 'recurrence': [], + 'visibility': 'default', + 'attendees': [], + 'reminders': {'useDefault': False}, + 'attachments': [], + 'guestsCanInviteOthers': True, + 'guestsCanModify': False, + 'guestsCanSeeOtherGuests': True, + } + self.assertDictEqual(EventSerializer.to_json(e), expected_event_json) + + def test_to_object(self): + event_json = { + 'summary': 'Good day', + 'description': 'Very good day indeed', + 'location': 'Prague', + 'start': {'dateTime': '2019-01-01T11:22:33', 'timeZone': TEST_TIMEZONE}, + 'end': {'dateTime': '2019-01-01T12:22:33', 'timeZone': TEST_TIMEZONE}, + 'updated': '2020-11-25T14:53:46.0Z', + 'created': '2020-11-24T14:53:46.0Z', + 'recurrence': [ + 'RRULE:FREQ=DAILY;WKST=SU', + 'EXRULE:FREQ=DAILY;BYDAY=MO;WKST=SU', + 'EXDATE:VALUE=DATE:20190419,20190422,20190512' + ], + 'visibility': 'public', + 'attendees': [ + {'email': 'attendee@gmail.com', 'responseStatus': ResponseStatus.NEEDS_ACTION}, + {'email': 'attendee2@gmail.com', 'responseStatus': ResponseStatus.ACCEPTED}, + ], + 'reminders': { + 'useDefault': False, + 'overrides': [ + {'method': 'popup', 'minutes': 30}, + {'method': 'email', 'minutes': 120} + ] + }, + 'attachments': [ + { + 'title': 'My file1', + 'fileUrl': 'https://file.url1', + 'mimeType': 'application/vnd.google-apps.document' + }, + { + 'title': 'My file2', + 'fileUrl': 'https://file.url2', + 'mimeType': 'application/vnd.google-apps.document' + } + ], + 'conferenceData': { + 'entryPoints': [ + { + 'entryPointType': 'video', + 'uri': 'https://video.com', + } + ], + 'conferenceSolution': { + 'key': { + 'type': 'hangoutsMeet' + }, + 'name': 'Hangout', + 'iconUri': 'https://icon.com' + }, + 'conferenceId': 'aaa-bbbb-ccc', + 'signature': 'abc4efg12345', + 'notes': 'important notes' + }, + 'guestsCanInviteOthers': False, + 'guestsCanModify': True, + 'guestsCanSeeOtherGuests': False, + 'transparency': 'transparent', + 'creator': { + 'id': '123123', + 'email': 'creator@gmail.com', + 'displayName': 'Creator', + 'self': True + }, + 'organizer': { + 'id': '456456', + 'email': 'organizer@gmail.com', + 'displayName': 'Organizer', + 'self': False + } + } + + serializer = EventSerializer(event_json) + event = serializer.get_object() + + self.assertEqual(event.summary, 'Good day') + self.assertEqual(event.start, ensure_localisation((1 / Jan / 2019)[11:22:33], TEST_TIMEZONE)) + self.assertEqual(event.end, ensure_localisation((1 / Jan / 2019)[12:22:33], TEST_TIMEZONE)) + self.assertEqual(event.updated, ensure_localisation((25 / Nov / 2020)[14:53:46], 'UTC')) + self.assertEqual(event.created, ensure_localisation((24 / Nov / 2020)[14:53:46], 'UTC')) + self.assertEqual(event.description, 'Very good day indeed') + self.assertEqual(event.location, 'Prague') + self.assertEqual(len(event.recurrence), 3) + self.assertEqual(event.visibility, Visibility.PUBLIC) + self.assertEqual(len(event.attendees), 2) + self.assertIsInstance(event.reminders[0], PopupReminder) + self.assertEqual(event.reminders[0].minutes_before_start, 30) + self.assertIsInstance(event.reminders[1], EmailReminder) + self.assertEqual(event.reminders[1].minutes_before_start, 120) + self.assertEqual(len(event.attachments), 2) + self.assertIsInstance(event.attachments[0], Attachment) + self.assertEqual(event.attachments[0].title, 'My file1') + self.assertIsInstance(event.conference_solution, ConferenceSolution) + self.assertEqual(event.conference_solution.solution_type, 'hangoutsMeet') + self.assertEqual(event.conference_solution.entry_points[0].uri, 'https://video.com') + self.assertFalse(event.guests_can_invite_others) + self.assertTrue(event.guests_can_modify) + self.assertFalse(event.guests_can_see_other_guests) + self.assertEqual(event.transparency, 'transparent') + self.assertEqual(event.creator.email, 'creator@gmail.com') + self.assertEqual(event.organizer.email, 'organizer@gmail.com') + + event_json_str = """{ + "summary": "Good day", + "description": "Very good day indeed", + "location": "Prague", + "start": {"date": "2020-07-20"}, + "end": {"date": "2020-07-22"} + }""" + + event = EventSerializer.to_object(event_json_str) + + self.assertEqual(event.summary, 'Good day') + self.assertEqual(event.description, 'Very good day indeed') + self.assertEqual(event.location, 'Prague') + self.assertEqual(event.start, 20 / Jul / 2020) + self.assertEqual(event.end, 22 / Jul / 2020) + + def test_to_object_recurring_event(self): + event_json_str = { + "id": 'recurring_event_id_20201107T070000Z', + "summary": "Good day", + "description": "Very good day indeed", + "location": "Prague", + "start": {"date": "2020-07-20"}, + "end": {"date": "2020-07-22"}, + "recurringEventId": 'recurring_event_id' + } + + event = EventSerializer.to_object(event_json_str) + + self.assertEqual(event.id, 'recurring_event_id_20201107T070000Z') + self.assertTrue(event.is_recurring_instance) + self.assertEqual(event.recurring_event_id, 'recurring_event_id') + + def test_to_object_conference_data(self): + event_json = { + 'summary': 'Good day', + 'description': 'Very good day indeed', + 'location': 'Prague', + 'start': {'dateTime': '2019-01-01T11:22:33', 'timeZone': TEST_TIMEZONE}, + 'end': {'dateTime': '2019-01-01T12:22:33', 'timeZone': TEST_TIMEZONE}, + 'conferenceData': { + 'createRequest': { + 'requestId': 'hello1234', + 'conferenceSolutionKey': { + 'type': 'hangoutsMeet' + }, + 'status': { + 'statusCode': 'pending' + } + }, + 'conferenceId': 'conference-id', + 'signature': 'signature', + 'notes': 'important notes' + } + } + + event = EventSerializer.to_object(event_json) + self.assertIsInstance(event.conference_solution, ConferenceSolutionCreateRequest) + self.assertEqual(event.conference_solution.solution_type, 'hangoutsMeet') + + # with successful conference create request + event_json = { + 'summary': 'Good day', + 'description': 'Very good day indeed', + 'location': 'Prague', + 'start': {'dateTime': '2019-01-01T11:22:33', 'timeZone': TEST_TIMEZONE}, + 'end': {'dateTime': '2019-01-01T12:22:33', 'timeZone': TEST_TIMEZONE}, + 'conferenceData': { + 'entryPoints': [ + { + 'entryPointType': 'video', + 'uri': 'https://video.com', + } + ], + 'conferenceSolution': { + 'key': { + 'type': 'hangoutsMeet' + }, + 'name': 'Hangout', + 'iconUri': 'https://icon.com' + }, + 'createRequest': { + 'requestId': 'hello1234', + 'conferenceSolutionKey': { + 'type': 'hangoutsMeet' + }, + 'status': { + 'statusCode': 'success' + } + }, + 'conferenceId': 'conference-id', + 'signature': 'signature', + 'notes': 'important notes' + } + } + + event = EventSerializer.to_object(event_json) + self.assertIsInstance(event.conference_solution, ConferenceSolution) + self.assertEqual(event.conference_solution.solution_type, 'hangoutsMeet') + self.assertEqual(event.conference_solution.entry_points[0].uri, 'https://video.com') diff --git a/google-calendar-simple-api/tests/test_free_busy.py b/google-calendar-simple-api/tests/test_free_busy.py new file mode 100644 index 0000000000000000000000000000000000000000..6a41fdc9352085356a0e61c02f78fc4d850ca1ba --- /dev/null +++ b/google-calendar-simple-api/tests/test_free_busy.py @@ -0,0 +1,193 @@ +from unittest import TestCase + +from beautiful_date import Mar + +from gcsa.free_busy import FreeBusy, TimeRange +from gcsa.serializers.free_busy_serializer import FreeBusySerializer + + +class TestFreeBusy(TestCase): + def test_iter(self): + free_busy = FreeBusy( + time_min=(24 / Mar / 2023)[13:22], + time_max=(25 / Mar / 2023)[13:22], + groups={}, + calendars={ + 'calendar1': [ + TimeRange((24 / Mar / 2023)[14:22], (24 / Mar / 2023)[15:22]), + TimeRange((24 / Mar / 2023)[17:22], (24 / Mar / 2023)[18:22]), + ] + } + ) + + ranges = list(free_busy) + self.assertEqual(len(ranges), 2) + self.assertEqual(ranges[0], free_busy.calendars['calendar1'][0]) + self.assertEqual(ranges[1], free_busy.calendars['calendar1'][1]) + + def test_iter_errors(self): + free_busy = FreeBusy( + time_min=(24 / Mar / 2023)[13:22], + time_max=(25 / Mar / 2023)[13:22], + groups={}, + calendars={ + 'calendar1': [ + TimeRange((24 / Mar / 2023)[14:22], (24 / Mar / 2023)[15:22]), + TimeRange((24 / Mar / 2023)[17:22], (24 / Mar / 2023)[18:22]), + ], + 'calendar2': [ + TimeRange((24 / Mar / 2023)[15:22], (24 / Mar / 2023)[16:22]), + TimeRange((24 / Mar / 2023)[18:22], (24 / Mar / 2023)[19:22]), + ] + } + ) + + with self.assertRaises(ValueError): + iter(free_busy) + + free_busy = FreeBusy( + time_min=(24 / Mar / 2023)[13:22], + time_max=(25 / Mar / 2023)[13:22], + groups={}, + calendars={ + 'calendar1': [ + TimeRange((24 / Mar / 2023)[14:22], (24 / Mar / 2023)[15:22]), + TimeRange((24 / Mar / 2023)[17:22], (24 / Mar / 2023)[18:22]), + ] + }, + calendars_errors={ + 'calendar2': ['notFound'] + } + ) + with self.assertRaises(ValueError): + iter(free_busy) + + free_busy = FreeBusy( + time_min=(24 / Mar / 2023)[13:22], + time_max=(25 / Mar / 2023)[13:22], + groups={}, + calendars={}, + calendars_errors={ + 'calendar1': ['notFound'] + } + ) + with self.assertRaises(ValueError): + iter(free_busy) + + def test_repr_str(self): + free_busy = FreeBusy( + time_min=(24 / Mar / 2023)[13:22], + time_max=(25 / Mar / 2023)[13:22], + groups={'group1': ['calendar1', 'calendar2']}, + calendars={ + 'calendar1': [ + TimeRange((24 / Mar / 2023)[14:22], (24 / Mar / 2023)[15:22]), + TimeRange((24 / Mar / 2023)[17:22], (24 / Mar / 2023)[18:22]), + ], + 'calendar2': [ + TimeRange((24 / Mar / 2023)[15:22], (24 / Mar / 2023)[16:22]), + TimeRange((24 / Mar / 2023)[18:22], (24 / Mar / 2023)[19:22]), + ] + } + ) + self.assertEqual(free_busy.__repr__(), "") + self.assertEqual(free_busy.__str__(), "") + + +class TestFreeBusySerializer(TestCase): + def test_to_json(self): + free_busy = FreeBusy( + time_min=(24 / Mar / 2023)[13:22], + time_max=(25 / Mar / 2023)[13:22], + groups={'group1': ['calendar1', 'calendar2']}, + calendars={ + 'calendar1': [ + TimeRange((24 / Mar / 2023)[14:22], (24 / Mar / 2023)[15:22]), + TimeRange((24 / Mar / 2023)[17:22], (24 / Mar / 2023)[18:22]), + ], + 'calendar2': [ + TimeRange((24 / Mar / 2023)[15:22], (24 / Mar / 2023)[16:22]), + TimeRange((24 / Mar / 2023)[18:22], (24 / Mar / 2023)[19:22]), + ] + }, + groups_errors={ + "non-existing-group": [ + { + "domain": "global", + "reason": "notFound" + } + ] + }, + calendars_errors={ + "non-existing-calendar": [ + { + "domain": "global", + "reason": "notFound" + } + ] + } + ) + + free_busy_json = FreeBusySerializer.to_json(free_busy) + self.assertEqual(free_busy_json['timeMin'], '2023-03-24T13:22:00') + self.assertEqual(free_busy_json['timeMax'], '2023-03-25T13:22:00') + self.assertIn('calendar1', free_busy_json['calendars']) + self.assertIn('calendar2', free_busy_json['calendars']) + self.assertIn('non-existing-calendar', free_busy_json['calendars']) + self.assertIn('group1', free_busy_json['groups']) + self.assertIn('non-existing-group', free_busy_json['groups']) + + def test_to_object(self): + free_busy_json = { + 'calendars': { + 'calendar1': { + 'busy': [{'start': '2023-03-24T14:22:00', 'end': '2023-03-24T15:22:00'}, + {'start': '2023-03-24T17:22:00', 'end': '2023-03-24T18:22:00'}], + }, + 'calendar2': { + 'busy': [{'start': '2023-03-24T15:22:00', 'end': '2023-03-24T16:22:00'}], + }, + 'non-existing-calendar': { + 'errors': [{'domain': 'global', 'reason': 'notFound'}] + } + }, + 'groups': { + 'group1': { + 'calendars': ['calendar1', 'calendar2'], + }, + 'non-existing-group': { + 'errors': [{'domain': 'global', 'reason': 'notFound'}] + } + }, + 'timeMin': '2023-03-24T13:22:00', + 'timeMax': '2023-03-25T13:22:00' + } + + free_busy = FreeBusySerializer.to_object(free_busy_json) + + self.assertEqual(free_busy.time_min, (24 / Mar / 2023)[13:22]) + self.assertEqual(free_busy.time_max, (25 / Mar / 2023)[13:22]) + + self.assertIn('calendar1', free_busy.calendars) + self.assertIn('calendar2', free_busy.calendars) + self.assertNotIn('calendar1', free_busy.calendars_errors) + self.assertNotIn('calendar2', free_busy.calendars_errors) + self.assertEqual(len(free_busy.calendars['calendar1']), 2) + self.assertEqual(len(free_busy.calendars['calendar2']), 1) + self.assertNotIn('non-existing-calendar', free_busy.calendars) + self.assertIn('non-existing-calendar', free_busy.calendars_errors) + + self.assertIn('group1', free_busy.groups) + self.assertNotIn('group1', free_busy.groups_errors) + self.assertEqual(len(free_busy.groups['group1']), 2) + self.assertIn('non-existing-group', free_busy.groups_errors) + self.assertNotIn('non-existing-group', free_busy.groups) + + free_busy_json = """{ + "timeMin": "2023-03-24T13:22:00", + "timeMax": "2023-03-25T13:22:00" + }""" + + free_busy = FreeBusySerializer(free_busy_json).to_object(free_busy_json) + self.assertEqual(free_busy.time_min, (24 / Mar / 2023)[13:22]) + self.assertEqual(free_busy.time_max, (25 / Mar / 2023)[13:22]) diff --git a/google-calendar-simple-api/tests/test_person.py b/google-calendar-simple-api/tests/test_person.py new file mode 100644 index 0000000000000000000000000000000000000000..ca2e65eddecb22e5e2e1d7c1466e315a6e8972ed --- /dev/null +++ b/google-calendar-simple-api/tests/test_person.py @@ -0,0 +1,44 @@ +from unittest import TestCase + +from gcsa.person import Person +from gcsa.serializers.person_serializer import PersonSerializer + + +class TestPerson(TestCase): + def test_repr_str(self): + person = Person( + email='mail@gmail.com', + display_name='Guest', + _id='123123', + _is_self=False + ) + self.assertEqual(person.__repr__(), "") + self.assertEqual(person.__str__(), "'mail@gmail.com' - 'Guest'") + + +class TestPersonSerializer(TestCase): + def test_to_json(self): + person = Person( + email='mail@gmail.com', + display_name='Organizer' + ) + + person_json = PersonSerializer(person).get_json() + + self.assertEqual(person.email, person_json['email']) + self.assertEqual(person.display_name, person_json['displayName']) + + def test_to_object(self): + person_json = { + 'email': 'mail2@gmail.com', + 'displayName': 'Creator', + 'id': '123123', + 'self': False + } + + person = PersonSerializer.to_object(person_json) + + self.assertEqual(person_json['email'], person.email) + self.assertEqual(person_json['displayName'], person.display_name) + self.assertEqual(person_json['id'], person.id_) + self.assertEqual(person_json['self'], person.is_self) diff --git a/google-calendar-simple-api/tests/test_recurrence.py b/google-calendar-simple-api/tests/test_recurrence.py new file mode 100644 index 0000000000000000000000000000000000000000..80b78cadfdef0fb53e28abe19d4fdc7279829e8b --- /dev/null +++ b/google-calendar-simple-api/tests/test_recurrence.py @@ -0,0 +1,212 @@ +from functools import partial +from unittest import TestCase + +from beautiful_date import Jun, Jul + +from gcsa.recurrence import Recurrence, \ + WEEKLY, MO, WE, TH, Duration + +TEST_TIMEZONE = 'Asia/Shanghai' + +r = Recurrence._rule +t = partial(Recurrence._times, timezone=TEST_TIMEZONE) +d = Recurrence._dates +p = partial(Recurrence._periods, timezone=TEST_TIMEZONE) + + +class TestRecurrence(TestCase): + def assert_rrule_equal(self, first, second, msg=None): + first_set = set(first.split(';')) + second_set = set(second.split(';')) + self.assertSetEqual(first_set, second_set, msg) + + def test__rule(self): + self.assert_rrule_equal(r(), 'FREQ=DAILY;WKST=SU') + self.assert_rrule_equal(r(freq=WEEKLY), 'FREQ=WEEKLY;WKST=SU') + self.assert_rrule_equal(r(interval=2), 'FREQ=DAILY;INTERVAL=2;WKST=SU') + self.assert_rrule_equal(r(count=5), 'FREQ=DAILY;COUNT=5;WKST=SU') + self.assert_rrule_equal(r(until=14 / Jun / 2020), 'FREQ=DAILY;UNTIL=20200614T000000Z;WKST=SU') + self.assert_rrule_equal(r(until=(14 / Jun / 2020)[15:49]), 'FREQ=DAILY;UNTIL=20200614T154900Z;WKST=SU') + self.assert_rrule_equal(r(by_second=13), 'FREQ=DAILY;BYSECOND=13;WKST=SU') + self.assert_rrule_equal(r(by_minute=44), 'FREQ=DAILY;BYMINUTE=44;WKST=SU') + self.assert_rrule_equal(r(by_hour=22), 'FREQ=DAILY;BYHOUR=22;WKST=SU') + self.assert_rrule_equal(r(by_week_day=WE), 'FREQ=DAILY;BYDAY=WE;WKST=SU') + self.assert_rrule_equal(r(by_week_day=TH(-1)), 'FREQ=DAILY;BYDAY=-1TH;WKST=SU') + self.assert_rrule_equal(r(by_month_day=30), 'FREQ=DAILY;BYMONTHDAY=30;WKST=SU') + self.assert_rrule_equal(r(by_year_day=48), 'FREQ=DAILY;BYYEARDAY=48;WKST=SU') + self.assert_rrule_equal(r(by_week=-51), 'FREQ=DAILY;BYWEEKNO=-51;WKST=SU') + self.assert_rrule_equal(r(by_month=4), 'FREQ=DAILY;BYMONTH=4;WKST=SU') + self.assert_rrule_equal(r(by_set_pos=4, by_month=3), 'FREQ=DAILY;BYSETPOS=4;BYMONTH=3;WKST=SU') + self.assert_rrule_equal(r(week_start=MO), 'FREQ=DAILY;WKST=MO') + self.assert_rrule_equal(r(week_start=MO(4)), 'FREQ=DAILY;WKST=4MO') + self.assert_rrule_equal(r(week_start=MO(-1)), 'FREQ=DAILY;WKST=-1MO') + + def test__rule_errors(self): + def assert_value_error(**kwargs): + with self.assertRaises(ValueError): + r(**kwargs) + + def assert_type_error(**kwargs): + with self.assertRaises(TypeError): + r(**kwargs) + + assert_value_error(freq='MILLISECONDLY') # MILLISECONDLY is not a valid frequency + + assert_value_error(interval=0) + assert_value_error(interval=-4) + + assert_value_error(count=0) + assert_value_error(count=-1) + + assert_type_error(until=15) + + assert_value_error(by_second=-1) + assert_value_error(by_second=[4, -4, 5]) + assert_value_error(by_second=[4, 61, 5]) + + assert_value_error(by_minute=-4) + assert_value_error(by_minute=[4, -4, 5]) + assert_value_error(by_minute=[4, 60, 5]) + + assert_value_error(by_hour=-4) + assert_value_error(by_hour=[4, -4, 5]) + assert_value_error(by_hour=[4, 60, 5]) + + assert_type_error(by_week_day=4) + + assert_value_error(by_month_day=0) + assert_value_error(by_month_day=32) + assert_value_error(by_month_day=-32) + assert_value_error(by_month_day=[1, -32, 5]) + assert_value_error(by_month_day=[1, 0, 5]) + + assert_value_error(by_year_day=0) + assert_value_error(by_year_day=367) + assert_value_error(by_year_day=-367) + assert_value_error(by_year_day=[1, -367, 5]) + assert_value_error(by_year_day=[1, 0, 5]) + + assert_value_error(by_week=0) + assert_value_error(by_week=54) + assert_value_error(by_week=-54) + assert_value_error(by_week=[1, -54, 5]) + assert_value_error(by_week=[1, 0, 5]) + + assert_value_error(by_month=0) + assert_value_error(by_month=13) + assert_value_error(by_month=-4) + assert_value_error(by_month=[1, -1, 5]) + assert_value_error(by_month=[1, 0, 5]) + + assert_value_error(week_start=3) + + assert_value_error(count=5, until=20 / Jul / 2020) + + assert_value_error(by_set_pos=5) + + def test__times(self): + def assert_times_equal(dts, rtimes): + self.assertEqual(t(dts), "TZID={}:".format(TEST_TIMEZONE) + rtimes) + + assert_times_equal((15 / Jun / 2020)[10:30], + "20200615T103000") + assert_times_equal([(15 / Jun / 2020)[10:30]], + "20200615T103000") + assert_times_equal([(15 / Jun / 2020)[10:30], (17 / Jul / 2020)[23:45]], + "20200615T103000,20200717T234500") + assert_times_equal([(15 / Jun / 2020)[10:30], (17 / Jul / 2020)], + "20200615T103000,20200717T000000") + + self.assertEqual(Recurrence.times((20 / Jul / 2020)[10:30], timezone=TEST_TIMEZONE), + 'RDATE;TZID=Asia/Shanghai:20200720T103000') + self.assertEqual(Recurrence.times([(20 / Jul / 2020)[10:30], (21 / Jul / 2020)[11:30]], timezone=TEST_TIMEZONE), + 'RDATE;TZID=Asia/Shanghai:20200720T103000,20200721T113000') + self.assertEqual(Recurrence.exclude_times((20 / Jul / 2020)[10:35], timezone=TEST_TIMEZONE), + 'EXDATE;TZID=Asia/Shanghai:20200720T103500') + self.assertEqual(Recurrence.exclude_times([(20 / Jul / 2020)[10:35], + (21 / Jul / 2020)[11:35]], timezone=TEST_TIMEZONE), + 'EXDATE;TZID=Asia/Shanghai:20200720T103500,20200721T113500') + + def test__times_errors(self): + with self.assertRaises(TypeError): + t("hello") + with self.assertRaises(TypeError): + t([(15 / Jun / 2020)[10:30], "hello"]) + + def test__dates(self): + def assert_dates_equal(ds, rdates): + self.assertEqual(d(ds), "VALUE=DATE:" + rdates) + + assert_dates_equal(15 / Jun / 2020, + "20200615") + assert_dates_equal([(15 / Jun / 2020)], + "20200615") + assert_dates_equal([15 / Jun / 2020, (17 / Jul / 2020)[23:45]], + "20200615,20200717") + assert_dates_equal([(15 / Jun / 2020)[10:30], (17 / Jul / 2020)], + "20200615,20200717") + + self.assertEqual(Recurrence.dates(20 / Jul / 2020), 'RDATE;VALUE=DATE:20200720') + self.assertEqual(Recurrence.dates([20 / Jul / 2020, 23 / Jul / 2020]), 'RDATE;VALUE=DATE:20200720,20200723') + self.assertEqual(Recurrence.exclude_dates(21 / Jul / 2020), 'EXDATE;VALUE=DATE:20200721') + self.assertEqual(Recurrence.exclude_dates([21 / Jul / 2020, 24 / Jul / 2020]), + 'EXDATE;VALUE=DATE:20200721,20200724') + + def test__dates_errors(self): + with self.assertRaises(TypeError): + d("hello") + with self.assertRaises(TypeError): + d([15 / Jun / 2020, "hello"]) + + def test__periods(self): + def assert_periods_equal(ps, rperiods): + self.assertEqual(p(ps), "VALUE=PERIOD:" + rperiods) + + assert_periods_equal(((15 / Jun / 2020), (17 / Jul / 2020)), + '20200615T000000Z/20200717T000000Z') + assert_periods_equal(((15 / Jun / 2020), Duration(w=2, d=1)), + '20200615T000000Z/P2W1D') + assert_periods_equal(((15 / Jun / 2020), Duration(w=2, d=1, m=10)), + '20200615T000000Z/P2W1DT10M') + assert_periods_equal(((15 / Jun / 2020), Duration(w=2, d=1, h=11, m=10, s=22)), + '20200615T000000Z/P2W1DT11H10M22S') + assert_periods_equal([((15 / Jun / 2020)[21:10], (17 / Jul / 2020)[22:12])], + '20200615T211000Z/20200717T221200Z') + assert_periods_equal([((15 / Jun / 2020)[21:10], (17 / Jul / 2020)[22:12])], + '20200615T211000Z/20200717T221200Z') + assert_periods_equal([((15 / Jun / 2020)[21:10], (17 / Jul / 2020)[22:12]), + ((15 / Jun / 2020)[21:10], Duration(w=2, d=1, m=10))], + '20200615T211000Z/20200717T221200Z,20200615T211000Z/P2W1DT10M') + + periods = partial(Recurrence.periods, timezone=TEST_TIMEZONE) + exclude_periods = partial(Recurrence.exclude_periods, timezone=TEST_TIMEZONE) + + self.assertEqual(periods(((20 / Jul / 2020), (22 / Jul / 2020))), + 'RDATE;VALUE=PERIOD:20200720T000000Z/20200722T000000Z') + self.assertEqual(periods([((20 / Jul / 2020), (22 / Jul / 2020)), + ((25 / Jul / 2020), Duration(w=2, d=1))]), + 'RDATE;VALUE=PERIOD:20200720T000000Z/20200722T000000Z,20200725T000000Z/P2W1D') + self.assertEqual(periods(((20 / Jul / 2020)[20:11], (22 / Jul / 2020)[20:12])), + 'RDATE;VALUE=PERIOD:20200720T201100Z/20200722T201200Z') + self.assertEqual(periods([((20 / Jul / 2020)[20:11], (22 / Jul / 2020)[20:12]), + ((25 / Jul / 2020)[20:11], Duration(w=2, d=1))]), + 'RDATE;VALUE=PERIOD:20200720T201100Z/20200722T201200Z,20200725T201100Z/P2W1D') + + self.assertEqual(exclude_periods(((20 / Jul / 2020), (22 / Jul / 2020))), + 'EXDATE;VALUE=PERIOD:20200720T000000Z/20200722T000000Z') + self.assertEqual(exclude_periods([((20 / Jul / 2020), (22 / Jul / 2020)), + ((25 / Jul / 2020), Duration(w=2, d=1))]), + 'EXDATE;VALUE=PERIOD:20200720T000000Z/20200722T000000Z,20200725T000000Z/P2W1D') + self.assertEqual(exclude_periods(((20 / Jul / 2020)[20:11], (22 / Jul / 2020)[20:12])), + 'EXDATE;VALUE=PERIOD:20200720T201100Z/20200722T201200Z') + self.assertEqual(exclude_periods([((20 / Jul / 2020)[20:11], (22 / Jul / 2020)[20:12]), + ((25 / Jul / 2020)[20:11], Duration(w=2, d=1))]), + 'EXDATE;VALUE=PERIOD:20200720T201100Z/20200722T201200Z,20200725T201100Z/P2W1D') + + def test__periods_errors(self): + with self.assertRaises(TypeError): + p((15 / Jun / 2020, "hello")) + with self.assertRaises(TypeError): + p([("Hello", 15 / Jun / 2020)]) + with self.assertRaises(TypeError): + p([(10 / Jun / 2020, 15 / Jun / 2020), ("Hello", 15 / Jun / 2020)]) diff --git a/google-calendar-simple-api/tests/test_reminder.py b/google-calendar-simple-api/tests/test_reminder.py new file mode 100644 index 0000000000000000000000000000000000000000..b888602c7ab472d869279a9e7d1add7f2321b6ff --- /dev/null +++ b/google-calendar-simple-api/tests/test_reminder.py @@ -0,0 +1,146 @@ +from datetime import time, datetime, date +from unittest import TestCase + +from beautiful_date import Apr + +from gcsa.reminders import Reminder, EmailReminder, PopupReminder +from gcsa.serializers.reminder_serializer import ReminderSerializer + + +class TestReminder(TestCase): + def test_email_reminder(self): + reminder = EmailReminder() + self.assertEqual(reminder.method, 'email') + self.assertEqual(reminder.minutes_before_start, 60) + + reminder = EmailReminder(34) + self.assertEqual(reminder.method, 'email') + self.assertEqual(reminder.minutes_before_start, 34) + + reminder = EmailReminder(days_before=1, at=time(0, 0)) + self.assertEqual(reminder.method, 'email') + self.assertEqual(reminder.minutes_before_start, None) + self.assertEqual(reminder.days_before, 1) + self.assertEqual(reminder.at, time(0, 0)) + + def test_popup_reminder(self): + reminder = PopupReminder() + self.assertEqual(reminder.method, 'popup') + self.assertEqual(reminder.minutes_before_start, 30) + + reminder = PopupReminder(51) + self.assertEqual(reminder.method, 'popup') + self.assertEqual(reminder.minutes_before_start, 51) + + reminder = PopupReminder(days_before=1, at=time(0, 0)) + self.assertEqual(reminder.method, 'popup') + self.assertEqual(reminder.minutes_before_start, None) + self.assertEqual(reminder.days_before, 1) + self.assertEqual(reminder.at, time(0, 0)) + + def test_repr_str(self): + reminder = EmailReminder(34) + self.assertEqual(reminder.__repr__(), "") + self.assertEqual(reminder.__str__(), "EmailReminder - minutes_before_start:34") + + reminder = PopupReminder(days_before=1, at=time(0, 0)) + self.assertEqual(reminder.__repr__(), "") + self.assertEqual(reminder.__str__(), "PopupReminder - 1 days before at 00:00:00") + + def test_absolute_reminders_conversion(self): + absolute_reminder = EmailReminder(days_before=1, at=time(12, 0)) + reminder = absolute_reminder.convert_to_relative(datetime(2024, 4, 16, 10, 15)) + self.assertEqual(reminder.method, 'email') + self.assertEqual(reminder.minutes_before_start, (12 + 10) * 60 + 15) + + absolute_reminder = PopupReminder(days_before=2, at=time(11, 30)) + reminder = absolute_reminder.convert_to_relative(date(2024, 4, 16)) + self.assertEqual(reminder.method, 'popup') + self.assertEqual(reminder.minutes_before_start, 24 * 60 + 12 * 60 + 30) + + absolute_reminder = PopupReminder(days_before=5, at=time(10, 25)) + reminder = absolute_reminder.convert_to_relative(16 / Apr / 2024) + self.assertEqual(reminder.method, 'popup') + self.assertEqual(reminder.minutes_before_start, 4 * 24 * 60 + 13 * 60 + 35) + + def test_reminder_checks(self): + # No time provided + with self.assertRaises(ValueError): + Reminder(method='email') + + # Both relative and absolute times provided + with self.assertRaises(ValueError): + Reminder(method='email', minutes_before_start=22, days_before=1) + with self.assertRaises(ValueError): + Reminder(method='email', minutes_before_start=22, at=time(0, 0)) + + # Only one of days_before and at provided + with self.assertRaises(ValueError): + Reminder(method='email', days_before=1) + with self.assertRaises(ValueError): + Reminder(method='email', at=time(0, 0)) + with self.assertRaises(ValueError): + PopupReminder(days_before=1) + with self.assertRaises(ValueError): + EmailReminder(at=time(0, 0)) + + +class TestReminderSerializer(TestCase): + def test_to_json(self): + reminder_json = { + 'method': 'email', + 'minutes': 55 + } + reminder = EmailReminder(55) + + self.assertDictEqual(ReminderSerializer.to_json(reminder), reminder_json) + + reminder_json = { + 'method': 'popup', + 'minutes': 13 + } + reminder = PopupReminder(13) + + self.assertDictEqual(ReminderSerializer.to_json(reminder), reminder_json) + + serializer = ReminderSerializer(reminder) + self.assertDictEqual(serializer.get_json(), reminder_json) + + def test_to_object(self): + reminder_json = { + 'method': 'email', + 'minutes': 55 + } + + reminder = ReminderSerializer.to_object(reminder_json) + + self.assertIsInstance(reminder, EmailReminder) + self.assertEqual(reminder.minutes_before_start, 55) + + reminder_json = { + 'method': 'popup', + 'minutes': 33 + } + + reminder = ReminderSerializer.to_object(reminder_json) + + self.assertIsInstance(reminder, PopupReminder) + self.assertEqual(reminder.minutes_before_start, 33) + + reminder_json_str = """{ + "method": "popup", + "minutes": 22 + }""" + + reminder = ReminderSerializer.to_object(reminder_json_str) + + self.assertIsInstance(reminder, PopupReminder) + self.assertEqual(reminder.minutes_before_start, 22) + + with self.assertRaises(ValueError): + reminder_json = { + 'method': 'telegram', + 'minutes': 33 + } + + ReminderSerializer.to_object(reminder_json) diff --git a/google-calendar-simple-api/tests/test_settings.py b/google-calendar-simple-api/tests/test_settings.py new file mode 100644 index 0000000000000000000000000000000000000000..5a43a02214668979c6211129ab28dc3d1abe69b0 --- /dev/null +++ b/google-calendar-simple-api/tests/test_settings.py @@ -0,0 +1,101 @@ +from unittest import TestCase + +from gcsa.serializers.settings_serializer import SettingsSerializer +from gcsa.settings import Settings + + +class TestSettings(TestCase): + def test_repr_str(self): + settings = Settings( + auto_add_hangouts=True, + date_field_order='DMY', + default_event_length=45, + format24_hour_time=True, + hide_invitations=True, + hide_weekends=True, + locale='cz', + remind_on_responded_events_only=True, + show_declined_events=False, + timezone='Europe/Prague', + use_keyboard_shortcuts=False, + week_start=1, + ) + expected_str = \ + "User settings:\n" \ + "auto_add_hangouts=True\n" \ + "date_field_order=DMY\n" \ + "default_event_length=45\n" \ + "format24_hour_time=True\n" \ + "hide_invitations=True\n" \ + "hide_weekends=True\n" \ + "locale=cz\n" \ + "remind_on_responded_events_only=True\n" \ + "show_declined_events=False\n" \ + "timezone=Europe/Prague\n" \ + "use_keyboard_shortcuts=False\n" \ + "week_start=1" + self.assertEqual(settings.__str__(), expected_str) + self.assertEqual(settings.__repr__(), expected_str) + + +class TestSettingsSerializer(TestCase): + def test_to_json(self): + settings = Settings( + auto_add_hangouts=True, + date_field_order='DMY', + default_event_length=45, + format24_hour_time=True, + hide_invitations=True, + hide_weekends=True, + locale='cz', + remind_on_responded_events_only=True, + show_declined_events=False, + timezone='Europe/Prague', + use_keyboard_shortcuts=False, + week_start=1, + ) + expected_json = { + 'autoAddHangouts': settings.auto_add_hangouts, + 'dateFieldOrder': settings.date_field_order, + 'defaultEventLength': settings.default_event_length, + 'format24HourTime': settings.format24_hour_time, + 'hideInvitations': settings.hide_invitations, + 'hideWeekends': settings.hide_weekends, + 'locale': settings.locale, + 'remindOnRespondedEventsOnly': settings.remind_on_responded_events_only, + 'showDeclinedEvents': settings.show_declined_events, + 'timezone': settings.timezone, + 'useKeyboardShortcuts': settings.use_keyboard_shortcuts, + 'weekStart': settings.week_start + } + self.assertDictEqual(SettingsSerializer(settings).get_json(), expected_json) + + def test_to_object(self): + settings_json = { + 'autoAddHangouts': True, + 'dateFieldOrder': 'DMY', + 'defaultEventLength': 45, + 'format24HourTime': True, + 'hideInvitations': True, + 'hideWeekends': True, + 'locale': 'cz', + 'remindOnRespondedEventsOnly': True, + 'showDeclinedEvents': False, + 'timezone': 'Europe/Prague', + 'useKeyboardShortcuts': False, + 'weekStart': 1, + } + settings = SettingsSerializer(settings_json).get_object() + + self.assertTrue(settings.auto_add_hangouts) + self.assertEqual(settings.date_field_order, 'DMY') + self.assertEqual(settings.default_event_length, 45) + self.assertTrue(settings.format24_hour_time) + self.assertTrue(settings.hide_invitations) + self.assertTrue(settings.hide_weekends) + self.assertEqual(settings.locale, 'cz') + self.assertTrue(settings.remind_on_responded_events_only) + self.assertFalse(settings.show_declined_events) + self.assertEqual(settings.timezone, 'Europe/Prague') + self.assertFalse(settings.use_keyboard_shortcuts) + self.assertEqual(settings.week_start, 1) diff --git a/google-calendar-simple-api/tests/test_util.py b/google-calendar-simple-api/tests/test_util.py new file mode 100644 index 0000000000000000000000000000000000000000..76c29f0cd8f77418feb1335ab2df891625815695 --- /dev/null +++ b/google-calendar-simple-api/tests/test_util.py @@ -0,0 +1,25 @@ +from unittest import TestCase + +from beautiful_date import Sept + +from gcsa.util.date_time_util import ensure_localisation + + +class TestReminder(TestCase): + def test_ensure_localisation(self): + initial_date = 23 / Sept / 2022 + d = ensure_localisation(initial_date) + # Shouldn't do anything to date + self.assertEqual(initial_date, d) + + initial_date_time = initial_date[:] + self.assertIsNone(initial_date_time.tzinfo) + dt_with_tz = ensure_localisation(initial_date_time) + self.assertIsNotNone(dt_with_tz.tzinfo) + self.assertNotEqual(dt_with_tz, initial_date_time) + + dt_with_tz_unchanged = ensure_localisation(dt_with_tz) + self.assertEqual(dt_with_tz, dt_with_tz_unchanged) + + with self.assertRaises(TypeError): + ensure_localisation('Hello') diff --git a/google-calendar-simple-api/tox.ini b/google-calendar-simple-api/tox.ini new file mode 100644 index 0000000000000000000000000000000000000000..fbd754f593a926a4c0fb35b514d5704acbc90de3 --- /dev/null +++ b/google-calendar-simple-api/tox.ini @@ -0,0 +1,59 @@ +[tox] +envlist = pytest, code-cov, flake8, sphinx + +[gh-actions] +python = + 3.6: pytest + 3.7: pytest + 3.8: pytest + 3.9: pytest + 3.10: pytest + 3.11: pytest + 3.12: pytest, flake8, sphinx + +[flake8] +max-line-length = 120 +per-file-ignores = + # naming conventions broken by googleapiclient + tests/google_calendar_tests/mock_services/*: N802,N803 + +[coverage:report] +exclude_lines = + # Have to re-enable the standard pragma + pragma: no cover + + # Don't complain if tests don't hit defensive assertion code: + pass +omit = + */__init__.py + + + +[testenv:pytest] +deps = + pyfakefs + pytest +commands = + pytest + +[testenv:coverage] +deps = + pyfakefs + pytest + pytest-cov +commands = + pytest --cov-report xml --cov=gcsa tests + +[testenv:flake8] +deps = + flake8 + pep8-naming +commands = + flake8 gcsa tests setup.py + +[testenv:sphinx] +deps = + sphinx + sphinx_rtd_theme +commands = + sphinx-build -W docs/source docs/build diff --git a/img.jpg b/img.jpg new file mode 100644 index 0000000000000000000000000000000000000000..955362ba01e5d9daf39e33bdb4fdb6e02a3002c7 Binary files /dev/null and b/img.jpg differ diff --git a/machinereadingcomprehension.ipynb b/machinereadingcomprehension.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..46946c9e7f9b5bf8026737b7a03ee5088fee1dfa --- /dev/null +++ b/machinereadingcomprehension.ipynb @@ -0,0 +1,242 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "d02ed49e", + "metadata": { + "_cell_guid": "b1076dfc-b9ad-4769-8c92-a6c4dae69d19", + "_uuid": "8f2839f25d086af736a60e9eeb907d3b93b6e0e5", + "execution": { + "iopub.execute_input": "2024-06-12T20:29:48.416852Z", + "iopub.status.busy": "2024-06-12T20:29:48.416562Z", + "iopub.status.idle": "2024-06-12T20:29:50.953093Z", + "shell.execute_reply": "2024-06-12T20:29:50.951903Z" + }, + "papermill": { + "duration": 2.542116, + "end_time": "2024-06-12T20:29:50.955196", + "exception": false, + "start_time": "2024-06-12T20:29:48.413080", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "/kaggle/working\n", + "Cloning into 'MRC-RetroReader'...\r\n", + "remote: Enumerating objects: 266, done.\u001b[K\r\n", + "remote: Counting objects: 100% (266/266), done.\u001b[K\r\n", + "remote: Compressing objects: 100% (196/196), done.\u001b[K\r\n", + "remote: Total 266 (delta 175), reused 151 (delta 66), pack-reused 0\u001b[K\r\n", + "Receiving objects: 100% (266/266), 66.67 KiB | 5.55 MiB/s, done.\r\n", + "Resolving deltas: 100% (175/175), done.\r\n", + "/kaggle/working/MRC-RetroReader\n" + ] + } + ], + "source": [ + "!rm -rf /kaggle/working/MRC-RetroReader\n", + "%cd /kaggle/working/\n", + "!git clone https://github.com/phanhoang1803/MRC-RetroReader.git\n", + "%cd MRC-RetroReader" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "3fb6d32c", + "metadata": { + "execution": { + "iopub.execute_input": "2024-06-12T20:29:50.962630Z", + "iopub.status.busy": "2024-06-12T20:29:50.962323Z", + "iopub.status.idle": "2024-06-12T20:30:05.059999Z", + "shell.execute_reply": "2024-06-12T20:30:05.058891Z" + }, + "papermill": { + "duration": 14.104088, + "end_time": "2024-06-12T20:30:05.062408", + "exception": false, + "start_time": "2024-06-12T20:29:50.958320", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Collecting evaluate==0.4.2 (from -r requirements.txt (line 1))\r\n", + " Downloading evaluate-0.4.2-py3-none-any.whl.metadata (9.3 kB)\r\n", + "Requirement already satisfied: datasets>=2.0.0 in /opt/conda/lib/python3.10/site-packages (from evaluate==0.4.2->-r requirements.txt (line 1)) (2.19.2)\r\n", + "Requirement already satisfied: numpy>=1.17 in /opt/conda/lib/python3.10/site-packages (from evaluate==0.4.2->-r requirements.txt (line 1)) (1.26.4)\r\n", + "Requirement already satisfied: dill in /opt/conda/lib/python3.10/site-packages (from evaluate==0.4.2->-r requirements.txt (line 1)) (0.3.8)\r\n", + "Requirement already satisfied: pandas in /opt/conda/lib/python3.10/site-packages (from evaluate==0.4.2->-r requirements.txt (line 1)) (2.2.1)\r\n", + "Requirement already satisfied: requests>=2.19.0 in /opt/conda/lib/python3.10/site-packages (from evaluate==0.4.2->-r requirements.txt (line 1)) (2.32.3)\r\n", + "Requirement already satisfied: tqdm>=4.62.1 in /opt/conda/lib/python3.10/site-packages (from evaluate==0.4.2->-r requirements.txt (line 1)) (4.66.4)\r\n", + "Requirement already satisfied: xxhash in /opt/conda/lib/python3.10/site-packages (from evaluate==0.4.2->-r requirements.txt (line 1)) (3.4.1)\r\n", + "Requirement already satisfied: multiprocess in /opt/conda/lib/python3.10/site-packages (from evaluate==0.4.2->-r requirements.txt (line 1)) (0.70.16)\r\n", + "Requirement already satisfied: fsspec>=2021.05.0 in /opt/conda/lib/python3.10/site-packages (from fsspec[http]>=2021.05.0->evaluate==0.4.2->-r requirements.txt (line 1)) (2024.3.1)\r\n", + "Requirement already satisfied: huggingface-hub>=0.7.0 in /opt/conda/lib/python3.10/site-packages (from evaluate==0.4.2->-r requirements.txt (line 1)) (0.23.2)\r\n", + "Requirement already satisfied: packaging in /opt/conda/lib/python3.10/site-packages (from evaluate==0.4.2->-r requirements.txt (line 1)) (21.3)\r\n", + "Requirement already satisfied: filelock in /opt/conda/lib/python3.10/site-packages (from datasets>=2.0.0->evaluate==0.4.2->-r requirements.txt (line 1)) (3.13.1)\r\n", + "Requirement already satisfied: pyarrow>=12.0.0 in /opt/conda/lib/python3.10/site-packages (from datasets>=2.0.0->evaluate==0.4.2->-r requirements.txt (line 1)) (14.0.2)\r\n", + "Requirement already satisfied: pyarrow-hotfix in /opt/conda/lib/python3.10/site-packages (from datasets>=2.0.0->evaluate==0.4.2->-r requirements.txt (line 1)) (0.6)\r\n", + "Requirement already satisfied: aiohttp in /opt/conda/lib/python3.10/site-packages (from datasets>=2.0.0->evaluate==0.4.2->-r requirements.txt (line 1)) (3.9.1)\r\n", + "Requirement already satisfied: pyyaml>=5.1 in /opt/conda/lib/python3.10/site-packages (from datasets>=2.0.0->evaluate==0.4.2->-r requirements.txt (line 1)) (6.0.1)\r\n", + "Requirement already satisfied: typing-extensions>=3.7.4.3 in /opt/conda/lib/python3.10/site-packages (from huggingface-hub>=0.7.0->evaluate==0.4.2->-r requirements.txt (line 1)) (4.9.0)\r\n", + "Requirement already satisfied: pyparsing!=3.0.5,>=2.0.2 in /opt/conda/lib/python3.10/site-packages (from packaging->evaluate==0.4.2->-r requirements.txt (line 1)) (3.1.1)\r\n", + "Requirement already satisfied: charset-normalizer<4,>=2 in /opt/conda/lib/python3.10/site-packages (from requests>=2.19.0->evaluate==0.4.2->-r requirements.txt (line 1)) (3.3.2)\r\n", + "Requirement already satisfied: idna<4,>=2.5 in /opt/conda/lib/python3.10/site-packages (from requests>=2.19.0->evaluate==0.4.2->-r requirements.txt (line 1)) (3.6)\r\n", + "Requirement already satisfied: urllib3<3,>=1.21.1 in /opt/conda/lib/python3.10/site-packages (from requests>=2.19.0->evaluate==0.4.2->-r requirements.txt (line 1)) (1.26.18)\r\n", + "Requirement already satisfied: certifi>=2017.4.17 in /opt/conda/lib/python3.10/site-packages (from requests>=2.19.0->evaluate==0.4.2->-r requirements.txt (line 1)) (2024.2.2)\r\n", + "Requirement already satisfied: python-dateutil>=2.8.2 in /opt/conda/lib/python3.10/site-packages (from pandas->evaluate==0.4.2->-r requirements.txt (line 1)) (2.9.0.post0)\r\n", + "Requirement already satisfied: pytz>=2020.1 in /opt/conda/lib/python3.10/site-packages (from pandas->evaluate==0.4.2->-r requirements.txt (line 1)) (2023.3.post1)\r\n", + "Requirement already satisfied: tzdata>=2022.7 in /opt/conda/lib/python3.10/site-packages (from pandas->evaluate==0.4.2->-r requirements.txt (line 1)) (2023.4)\r\n", + "Requirement already satisfied: attrs>=17.3.0 in /opt/conda/lib/python3.10/site-packages (from aiohttp->datasets>=2.0.0->evaluate==0.4.2->-r requirements.txt (line 1)) (23.2.0)\r\n", + "Requirement already satisfied: multidict<7.0,>=4.5 in /opt/conda/lib/python3.10/site-packages (from aiohttp->datasets>=2.0.0->evaluate==0.4.2->-r requirements.txt (line 1)) (6.0.4)\r\n", + "Requirement already satisfied: yarl<2.0,>=1.0 in /opt/conda/lib/python3.10/site-packages (from aiohttp->datasets>=2.0.0->evaluate==0.4.2->-r requirements.txt (line 1)) (1.9.3)\r\n", + "Requirement already satisfied: frozenlist>=1.1.1 in /opt/conda/lib/python3.10/site-packages (from aiohttp->datasets>=2.0.0->evaluate==0.4.2->-r requirements.txt (line 1)) (1.4.1)\r\n", + "Requirement already satisfied: aiosignal>=1.1.2 in /opt/conda/lib/python3.10/site-packages (from aiohttp->datasets>=2.0.0->evaluate==0.4.2->-r requirements.txt (line 1)) (1.3.1)\r\n", + "Requirement already satisfied: async-timeout<5.0,>=4.0 in /opt/conda/lib/python3.10/site-packages (from aiohttp->datasets>=2.0.0->evaluate==0.4.2->-r requirements.txt (line 1)) (4.0.3)\r\n", + "Requirement already satisfied: six>=1.5 in /opt/conda/lib/python3.10/site-packages (from python-dateutil>=2.8.2->pandas->evaluate==0.4.2->-r requirements.txt (line 1)) (1.16.0)\r\n", + "Downloading evaluate-0.4.2-py3-none-any.whl (84 kB)\r\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m84.1/84.1 kB\u001b[0m \u001b[31m2.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\r\n", + "\u001b[?25hInstalling collected packages: evaluate\r\n", + "Successfully installed evaluate-0.4.2\r\n" + ] + } + ], + "source": [ + "!pip install -r requirements.txt" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "7e807b1b", + "metadata": { + "execution": { + "iopub.execute_input": "2024-06-12T20:30:05.072050Z", + "iopub.status.busy": "2024-06-12T20:30:05.071722Z", + "iopub.status.idle": "2024-06-12T20:30:07.433604Z", + "shell.execute_reply": "2024-06-12T20:30:07.432725Z" + }, + "papermill": { + "duration": 2.369129, + "end_time": "2024-06-12T20:30:07.435926", + "exception": false, + "start_time": "2024-06-12T20:30:05.066797", + "status": "completed" + }, + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "W&B offline. Running your script from this directory will only write metadata locally. Use wandb disabled to completely turn off W&B.\r\n" + ] + } + ], + "source": [ + "!wandb off\n", + "import warnings\n", + "warnings.filterwarnings('ignore')" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "8f66860e", + "metadata": { + "execution": { + "iopub.execute_input": "2024-06-12T20:30:07.445694Z", + "iopub.status.busy": "2024-06-12T20:30:07.445163Z", + "iopub.status.idle": "2024-06-12T20:30:07.449130Z", + "shell.execute_reply": "2024-06-12T20:30:07.448299Z" + }, + "papermill": { + "duration": 0.010983, + "end_time": "2024-06-12T20:30:07.451041", + "exception": false, + "start_time": "2024-06-12T20:30:07.440058", + "status": "completed" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "# !python train_squad_v2.py \\\n", + "# --configs configs/train_distilbert.yaml \\\n", + "# --batch_size 1024 \\\n", + "# --module intensive" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0ad87692", + "metadata": { + "papermill": { + "duration": 0.003639, + "end_time": "2024-06-12T20:30:07.458737", + "exception": false, + "start_time": "2024-06-12T20:30:07.455098", + "status": "completed" + }, + "tags": [] + }, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kaggle": { + "accelerator": "nvidiaTeslaT4", + "dataSources": [], + "dockerImageVersionId": 30732, + "isGpuEnabled": true, + "isInternetEnabled": true, + "language": "python", + "sourceType": "notebook" + }, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.13" + }, + "papermill": { + "default_parameters": {}, + "duration": 22.187427, + "end_time": "2024-06-12T20:30:07.679386", + "environment_variables": {}, + "exception": null, + "input_path": "__notebook__.ipynb", + "output_path": "__notebook__.ipynb", + "parameters": {}, + "start_time": "2024-06-12T20:29:45.491959", + "version": "2.5.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/outputs/intensive/intensive_eval_results.txt b/outputs/intensive/intensive_eval_results.txt new file mode 100644 index 0000000000000000000000000000000000000000..d52099d964c5aa74da948eea7566022bfd05a497 --- /dev/null +++ b/outputs/intensive/intensive_eval_results.txt @@ -0,0 +1,16 @@ +***** Eval results ***** +2024-06-13 02:57:20.310966epoch = 4.0 +eval_HasAns_exact = 0.0 +eval_HasAns_f1 = 0.0 +eval_HasAns_total = 5 +eval_best_exact = 0.0 +eval_best_exact_thresh = 0.0 +eval_best_f1 = 0.0 +eval_best_f1_thresh = 0.0 +eval_exact = 0.0 +eval_f1 = 0.0 +eval_runtime = 4.4682 +eval_samples_per_second = 1.119 +eval_steps_per_second = 0.224 +eval_total = 5 + diff --git a/outputs/intensive/nbest_predictions.json b/outputs/intensive/nbest_predictions.json new file mode 100644 index 0000000000000000000000000000000000000000..12a4d684d2330904ce6c8e9cfca66cd1fde7b8de --- /dev/null +++ b/outputs/intensive/nbest_predictions.json @@ -0,0 +1,130 @@ +{ + "id-01": [ + { + "start_logit": 5.459519386291504, + "end_logit": 5.520893096923828, + "text": "late 1990s", + "probability": 0.8416187167167664 + }, + { + "start_logit": 3.4294681549072266, + "end_logit": 5.520893096923828, + "text": "1990s", + "probability": 0.11052876710891724 + }, + { + "start_logit": 2.023928165435791, + "end_logit": 5.520893096923828, + "text": "the late 1990s", + "probability": 0.027105478569865227 + }, + { + "start_logit": 1.743208646774292, + "end_logit": 5.520893096923828, + "text": "in the late 1990s", + "probability": 0.020471150055527687 + }, + { + "start_logit": 5.459519386291504, + "end_logit": -3.1740002632141113, + "text": "late", + "probability": 0.00014091959747020155 + }, + { + "start_logit": 5.459519386291504, + "end_logit": -4.651952266693115, + "text": "late 1990s as lead singer of R&B girl-group Destiny's Child.", + "probability": 3.2144344004336745e-05 + }, + { + "start_logit": 5.459519386291504, + "end_logit": -4.683455467224121, + "text": "late 1990s as lead singer of R&B girl-group Destiny's Child", + "probability": 3.114749415544793e-05 + }, + { + "start_logit": -5.373571395874023, + "end_logit": 5.520893096923828, + "text": "rose to fame in the late 1990s", + "probability": 1.6609777958365157e-05 + }, + { + "start_logit": -5.5168046951293945, + "end_logit": 5.520893096923828, + "text": "fame in the late 1990s", + "probability": 1.4393232959264424e-05 + }, + { + "start_logit": 5.459519386291504, + "end_logit": -5.739414215087891, + "text": "late 1990s as", + "probability": 1.0834928616532125e-05 + }, + { + "start_logit": 5.459519386291504, + "end_logit": -6.014301300048828, + "text": "late 1990s as lead singer", + "probability": 8.230838830058929e-06 + }, + { + "start_logit": 2.023928165435791, + "end_logit": -3.1740002632141113, + "text": "the late", + "probability": 4.538505436357809e-06 + }, + { + "start_logit": 3.4294681549072266, + "end_logit": -4.651952266693115, + "text": "1990s as lead singer of R&B girl-group Destiny's Child.", + "probability": 4.221481958666118e-06 + }, + { + "start_logit": 3.4294681549072266, + "end_logit": -4.683455467224121, + "text": "1990s as lead singer of R&B girl-group Destiny's Child", + "probability": 4.09056292483001e-06 + }, + { + "start_logit": 1.743208646774292, + "end_logit": -3.1740002632141113, + "text": "in the late", + "probability": 3.4276604310434777e-06 + }, + { + "start_logit": 3.4294681549072266, + "end_logit": -5.739414215087891, + "text": "1990s as", + "probability": 1.422937998540874e-06 + }, + { + "start_logit": 3.4294681549072266, + "end_logit": -6.014301300048828, + "text": "1990s as lead singer", + "probability": 1.0809460491145728e-06 + }, + { + "start_logit": 2.023928165435791, + "end_logit": -4.651952266693115, + "text": "the late 1990s as lead singer of R&B girl-group Destiny's Child.", + "probability": 1.0352529216106632e-06 + }, + { + "start_logit": 2.023928165435791, + "end_logit": -4.683455467224121, + "text": "the late 1990s as lead singer of R&B girl-group Destiny's Child", + "probability": 1.0031470765170525e-06 + }, + { + "start_logit": 1.743208646774292, + "end_logit": -4.651952266693115, + "text": "in the late 1990s as lead singer of R&B girl-group Destiny's Child.", + "probability": 7.818644007784314e-07 + }, + { + "start_logit": -6.592185020446777, + "end_logit": -6.263244152069092, + "text": "", + "probability": 3.744041535136411e-11 + } + ] +} diff --git a/outputs/intensive/null_odds.json b/outputs/intensive/null_odds.json new file mode 100644 index 0000000000000000000000000000000000000000..20a37fbe06d07c2e1f04ce8bf9a051bfce55748d --- /dev/null +++ b/outputs/intensive/null_odds.json @@ -0,0 +1,3 @@ +{ + "id-01": -23.83584213256836 +} diff --git a/outputs/intensive/predictions.json b/outputs/intensive/predictions.json new file mode 100644 index 0000000000000000000000000000000000000000..30a4d66ecc8a8bc3e8fd36a42ad8b9ba453eb048 --- /dev/null +++ b/outputs/intensive/predictions.json @@ -0,0 +1,3 @@ +{ + "id-01": "late 1990s" +} diff --git a/outputs/sketch/cls_score.json b/outputs/sketch/cls_score.json new file mode 100644 index 0000000000000000000000000000000000000000..68d98191f4cc79c0f7534ee8710ecae6dafdeeca --- /dev/null +++ b/outputs/sketch/cls_score.json @@ -0,0 +1,3 @@ +{ + "id-01": 11.180146217346191 +} diff --git a/python b/python new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..c5396074c62d9f9fb010e18a50643cbd4d7312c1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +evaluate==0.4.2 +wandb +torch +transformers +scikit-learn +streamlit diff --git a/retro_reader/__init__.py b/retro_reader/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0ca31fd9c5b65af0d1dbc3aedb952c767efe6243 --- /dev/null +++ b/retro_reader/__init__.py @@ -0,0 +1,3 @@ +from .retro_reader import RetroReader + +__all__ = ["constants", "retro_reader", "args"] \ No newline at end of file diff --git a/retro_reader/args/__init__.py b/retro_reader/args/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..95219e4537c83835af3d6a088849ca39bc968441 --- /dev/null +++ b/retro_reader/args/__init__.py @@ -0,0 +1,3 @@ +from .retro_args import RetroArguments +from transformers import TrainingArguments +from .argparse import HfArgumentParser \ No newline at end of file diff --git a/retro_reader/args/argparse.py b/retro_reader/args/argparse.py new file mode 100644 index 0000000000000000000000000000000000000000..68840a24d9e4e86608b5c020ce13edf52c3f631f --- /dev/null +++ b/retro_reader/args/argparse.py @@ -0,0 +1,62 @@ +import dataclasses +import re +import copy +import yaml +from pathlib import Path +from dataclasses import dataclass, field +from typing import Any, Iterable, List, NewType, Optional, Tuple, Union, Dict + +from transformers.hf_argparser import DataClass, HfArgumentParser as OriginalHfArgumentParser + +DataClass = NewType("DataClass", Any) +DataClassType = NewType("DataClassType", Any) + +def lambda_field(default, **kwargs): + return field(default_factory=lambda: copy.copy(default)) + +class HfArgumentParser(OriginalHfArgumentParser): + def parse_yaml_file(self, yaml_file: str) -> Tuple[DataClass, ...]: + """ + Parse a YAML file and return a tuple of dataclass instances. + + Args: + yaml_file (str): Path to the YAML file. + + Returns: + Tuple[DataClass, ...]: A tuple of dataclass instances. + """ + # Create a custom YAML loader that allows parsing of floats with exponents + loader = yaml.SafeLoader + loader.add_implicit_resolver( + u'tag:yaml.org,2002:float', + re.compile(u'''^(?: + [-+]?(?:[0-9][0-9_]*)\\.[0-9_]*(?:[eE][-+]?[0-9]+)? + |[-+]?(?:[0-9][0-9_]*)(?:[eE][-+]?[0-9]+) + |\\.[0-9_]+(?:[eE][-+][0-9]+)? + |[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\\.[0-9_]* + |[-+]?\\.(?:inf|Inf|INF) + |\\.(?:nan|NaN|NAN))$''', re.X), + list(u'-+0123456789.') + ) + + # Load the YAML data from the file + data = yaml.load(Path(yaml_file).read_text(), Loader=loader) + + # Create a list to store the dataclass instances + outputs = [] + + # Iterate over each dataclass type + for dtype in self.dataclass_types: + # Get the names of the fields that are initialized + keys = {f.name for f in dataclasses.fields(dtype) if f.init} + # Get the name of the argument from the dataclass's mro + arg_name = dtype.__mro__[-2].__name__ + # Create a dictionary of the inputs for the dataclass + inputs = {k: v for k, v in data[arg_name].items() if k in keys} + # Create an instance of the dataclass with the inputs + obj = dtype(**inputs) + # Add the instance to the list + outputs.append(obj) + + # Return the list of dataclass instances as a tuple + return (*outputs,) diff --git a/retro_reader/args/default_config.yaml b/retro_reader/args/default_config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/retro_reader/args/retro_args.py b/retro_reader/args/retro_args.py new file mode 100644 index 0000000000000000000000000000000000000000..945f2a6f93d523ecc004611d1ed441bbccb5270b --- /dev/null +++ b/retro_reader/args/retro_args.py @@ -0,0 +1,220 @@ +from dataclasses import dataclass, field +from .. import models + +@dataclass +class RetroDataModelArguments: + pass + +@dataclass +class DataArguments(RetroDataModelArguments): + max_seq_length: int = field( + default=512, + metadata={ + "help": "The maximum total input sequence length after tokenization. Sequences longer " + "than this will be truncated, sequences shorter will be padded." + }, + ) + max_answer_length: int = field( + default=30, + metadata={ + "help": "Maximum length of an answer (in tokens) to be generated. This is not " + "a hard limit but the model's internal length limit." + }, + ) + doc_stride: int = field( + default=128, + metadata={ + "help": "When splitting up a long document into chunks, how much stride to take between chunks." + }, + ) + return_token_type_ids: bool = field( + default=True, + metadata={ + "help": "Whether to return token type ids." + }, + ) + pad_to_max_length: bool = field( + default=True, + metadata={ + "help": "Whether to pad all samples to `max_seq_length`. " + "If False, will pad the samples dynamically when batching to the maximum length in the batch (which can " + "be faster on GPU but will be slower on TPU)." + }, + ) + preprocessing_num_workers: int = field( + default=5, + metadata={ + "help": "The number of processes to use for the preprocessing." + }, + ) + overwrite_cache: bool = field( + default=False, + metadata={ + "help": "Overwrite the cached training and evaluation sets" + }, + ) + version_2_with_negative: bool = field( + default=True, + metadata={ + "help": "" + }, + ) + null_score_diff_threshold: float = field( + default=0.0, + metadata={ + "help": "If null_score - best_non_null is greater than the threshold predict null." + }, + ) + rear_threshold: float = field( + default=0.0, + metadata={ + "help": "Rear threshold." + }, + ) + n_best_size: int = field( + default=20, + metadata={ + "help": "The total number of n-best predictions to generate when looking for an answer." + }, + ) + use_choice_logits: bool = field( + default=False, + metadata={ + "help": "Whether to use choice logits." + }, + ) + start_n_top: int = field( + default=-1, + metadata={ + "help": "" + }, + ) + end_n_top: int = field( + default=-1, + metadata={ + "help": "" + }, + ) + beta1: int = field( + default=1, + metadata={ + "help": "" + }, + ) + beta2: int = field( + default=1, + metadata={ + "help": "" + }, + ) + best_cof: int = field( + default=1, + metadata={ + "help": "" + }, + ) + +@dataclass +class ModelArguments(RetroDataModelArguments): + use_auth_token: bool = field( + default=False, + metadata={ + # "help": "Will use the token generated when running `transformers-cli login` (necessary to use this script " + # "with private models)." + "help": "" + }, + ) + + +@dataclass +class SketchModelArguments(ModelArguments): + sketch_revision: str = field( + default="main", + metadata={ + "help": "The revision of the pretrained sketch model." + }, + ) + sketch_model_name: str = field( + default="monologg/koelectra-small-v3-discriminator", + metadata={ + "help": "The name of the pretrained sketch model." + }, + ) + sketch_model_mode: str = field( + default="finetune", + metadata={ + "help": "Choices = ['finetune', 'transfer']" + }, + ) + sketch_tokenizer_name: str = field( + default=None, + metadata={ + "help": "The name of the pretrained sketch tokenizer." + }, + ) + sketch_architectures: str = field( + default="ElectraForSequenceClassification", + metadata={ + "help": "" + }, + ) + + +@dataclass +class IntensiveModelArguments(ModelArguments): + intensive_revision: str = field( + default="main", + metadata={ + "help": "The revision of the pretrained intensive model." + }, + ) + intensive_model_name: str = field( + default="monologg/koelectra-base-v3-discriminator", + metadata={ + "help": "The name of the pretrained intensive model." + }, + ) + intensive_model_mode: str = field( + default="finetune", + metadata={ + "help": "Choices = ['finetune', 'transfer']" + }, + ) + intensive_tokenizer_name: str = field( + default=None, + metadata={ + "help": "The name of the pretrained intensive tokenizer." + }, + ) + intensive_architectures: str = field( + default="ElectraForQuestionAnsweringAVPool", + metadata={ + "help": "" + }, + ) + +@dataclass +class RetroArguments(DataArguments, SketchModelArguments, IntensiveModelArguments): + def __post_init__(self): + # Sketch + model_cls = getattr(models, self.sketch_architectures, None) + if model_cls is None: + raise ValueError(f"The sketch architecture '{self.sketch_architectures}' is not supported.") + # raise AttributeError + self.sketch_model_cls = model_cls + self.sketch_model_type = model_cls.model_type + if self.sketch_tokenizer_name is None: + self.sketch_tokenizer_name = self.sketch_model_name + + # Intensive + model_cls = getattr(models, self.intensive_architectures, None) + if model_cls is None: + raise AttributeError + self.intensive_model_cls = model_cls + self.intensive_model_type = model_cls.model_type + + # Tokenizer + if self.intensive_tokenizer_name is None: + self.intensive_tokenizer_name = self.intensive_model_name + + \ No newline at end of file diff --git a/retro_reader/base.py b/retro_reader/base.py new file mode 100644 index 0000000000000000000000000000000000000000..a73feabb591d0693334421afee1265b3de3920c0 --- /dev/null +++ b/retro_reader/base.py @@ -0,0 +1,313 @@ +import os +import gc +import time +import json +import math +import collections +from datetime import datetime +from typing import Optional, List, Dict, Tuple, Callable, Any, Union + +import torch +import numpy as np + +from transformers import ( + is_datasets_available, + is_torch_tpu_available, + is_torch_xla_available, +) + +from transformers.trainer_utils import ( + PredictionOutput, + EvalPrediction, + EvalLoopOutput, + denumpify_detensorize, + speed_metrics, +) + +from transformers.utils import logging +from transformers.debug_utils import DebugOption + +if is_datasets_available(): + import datasets + +# if is_torch_xla_available(): +# import torch_xla.core.xla_model as xm # type: ignore +# import torch_xla.debug.metrics as met # type: ignore + +from transformers import Trainer + +logger = logging.get_logger(__name__) + +class ToMixin: + def _optimizer_to(self, devide: str = "cpu"): + """ + Move the optimizer state to the specified device. + + Args: + devide (str, optional): The device to move the optimizer state to. Defaults to "cpu". + """ + for param in self.optimizer.state.values(): + if isinstance(param, torch.Tensor): + param.data = param.data.to(devide) + if param._grad is not None: + param._grad.data = param._grad.data.to(devide) + elif isinstance(param, dict): + for subparam in param.values(): + if isinstance(subparam, torch.Tensor): + subparam.data = subparam.data.to(devide) + if subparam._grad is not None: + subparam._grad.data = subparam._grad.data.to(devide) + + def _scheduler_to(self, devide: str = "cpu") -> None: + """ + Move the scheduler state to the specified device. + + Args: + devide (str, optional): The device to move the scheduler state to. Defaults to "cpu". + + Returns: + None + """ + for param in self.lr_scheduler.__dict__.values(): + if isinstance(param, torch.Tensor): + param.data = param.data.to(devide) + if param._grad is not None: + param._grad.data = param._grad.data.to(devide) + +class BaseReader(Trainer, ToMixin): + name: str = None + + def __init__( + self, + *args, # Passed to Trainer.__init__ + data_args = {}, # Additional arguments for data loading + eval_examples: datasets.Dataset = None, # Evaluation examples + **kwargs # Passed to Trainer.__init__ + ): + """ + Initializes the BaseReader. + + Args: + *args: Positional arguments passed to Trainer.__init__. + data_args (dict): Additional arguments for data loading. + eval_examples (datasets.Dataset): Evaluation examples. + **kwargs: Keyword arguments passed to Trainer.__init__. + """ + # Call the parent class's __init__ method with the given arguments + super().__init__(*args, **kwargs) + + # Set the data_args attribute + self.data_args = data_args + + # Set the eval_examples attribute + self.eval_examples = eval_examples + + def free_memory(self): + """ + Move the model, optimizer and scheduler state to the CPU, empty the CUDA cache and garbage collect. + + This method is useful to free up GPU memory before checkpointing the model or saving it to disk. + """ + self.model.to("cpu") + self._optimizer_to("cpu") + self._scheduler_to("cpu") + torch.cuda.empty_cache() + gc.collect() + + + def postprocess( + self, + output: EvalLoopOutput, + ) -> Union[Any, PredictionOutput]: + """ + Preprocess the evaluation loop output. + + This method is called after the evaluation loop has finished and before the evaluation metrics are computed. + It receives the output of the evaluation loop and can be used to modify it before it is passed to the compute_metrics function. + + Args: + output (EvalLoopOutput): The output of the evaluation loop. + + Returns: + Union[Any, PredictionOutput]: The modified output that will be passed to the compute_metrics function. + """ + return output + + + def evaluate( + self, + eval_dataset: Optional[datasets.Dataset] = None, + eval_examples: Optional[datasets.Dataset] = None, + ignore_keys: Optional[List[str]] = None, + metric_key_prefix: str = "eval", + ) -> Dict[str, float]: + """ + Evaluate the model on the given dataset. + + Args: + eval_dataset (Optional[datasets.Dataset], optional): The evaluation dataset. Defaults to None. + eval_examples (Optional[datasets.Dataset], optional): The evaluation examples. Defaults to None. + ignore_keys (Optional[List[str]], optional): Keys to ignore when calculating metrics. Defaults to None. + metric_key_prefix (str, optional): The prefix for metric keys. Defaults to "eval". + + Returns: + Dict[str, float]: The evaluation metrics. + """ + + # Start tracking memory usage + self._memory_tracker.start() + + # Set eval_dataset and eval_dataloader + eval_dataset = self.eval_dataset if eval_dataset is None else eval_dataset + eval_dataloader = self.get_eval_dataloader(eval_dataset) + + # Set eval_examples + eval_examples = self.eval_examples if eval_examples is None else eval_examples + + # Start timing + start_time = time.time() + + # Set compute_metrics + compute_metrics = self.compute_metrics + self.compute_metrics = None + + # Set eval_loop + eval_loop = self.prediction_loop if self.args.use_legacy_prediction_loop else self.evaluation_loop + try: + # Run evaluation loop + output = eval_loop( + eval_dataloader, + description="Evaluation", + # Only gather predictions if there are metrics to compute + prediction_loss_only=True if compute_metrics is None else None, + ignore_keys=ignore_keys, + metric_key_prefix=metric_key_prefix, + ) + finally: + # Restore compute_metrics + self.compute_metrics = compute_metrics + + # Set eval_dataset format + if isinstance(eval_dataset, datasets.Dataset): + eval_dataset.set_format( + type=eval_dataset.format["type"], + columns=list(eval_dataset.features.keys()), + ) + + # Postprocess output + eval_preds = self.postprocess(output, eval_examples, eval_dataset, mode="evaluate") + + # Compute metrics + metrics = {} + if self.compute_metrics is not None: + metrics = self.compute_metrics(eval_preds) + + # Make metrics JSON-serializable + metrics = denumpify_detensorize(metrics) + + # Prefix all keys with metric_key_prefix + '_' + for key in list(metrics.keys()): + if not key.startswith(f"{metric_key_prefix}_"): + metrics[f"{metric_key_prefix}_{key}"] = metrics.pop(key) + + # Add speed metrics + total_batch_size = self.args.eval_batch_size * self.args.world_size + metrics.update( + speed_metrics( + metric_key_prefix, + start_time, + num_samples=output.num_samples, + num_steps=math.ceil(output.num_samples / total_batch_size), + ) + ) + + # Log metrics + self.log(metrics) + + # Log and save evaluation results + filename = "eval_results.txt" + eval_result_file = self.name + '_' + filename if self.name else filename + with open(os.path.join(self.args.output_dir, eval_result_file), "w") as writer: + logger.info(f"***** Eval results *****") + writer.write("***** Eval results *****\n") + writer.write(f"{datetime.now()}") + for key in sorted(metrics.keys()): + logger.info(f" {key} = {metrics[key]}") + writer.write(f"{key} = {metrics[key]}\n") + writer.write("\n") + + # if DebugOption.TPU_METRICS_DEBUG and is_torch_xla_available(): + # # Log debug metrics for PyTorch/XLA + # xm.master_print(met.metrics_report()) + + # Call callback handler on evaluate + self.control = self.callback_handler.on_evaluate( + self.args, self.state, self.control, metrics + ) + + # Stop tracking memory usage and update metrics + self._memory_tracker.stop_and_update_metrics(metrics) + + return metrics + + def predict( + self, + test_dataset: datasets.Dataset, + test_examples: Optional[datasets.Dataset] = None, + ignore_keys: Optional[List[str]] = None, + metric_key_prefix: str = "test", + mode: bool = "predict", + ) -> PredictionOutput: + """ + Predicts on the given test dataset and returns the predictions. + + Args: + test_dataset (datasets.Dataset): The test dataset. + test_examples (Optional[datasets.Dataset], optional): The test examples. Defaults to None. + ignore_keys (Optional[List[str]], optional): Keys to ignore when calculating metrics. Defaults to None. + metric_key_prefix (str, optional): The prefix for metric keys. Defaults to "test". + mode (bool, optional): The mode of prediction. Defaults to "predict". + + Returns: + PredictionOutput: The predictions. + """ + + # Start tracking memory usage + self._memory_tracker.start() + + # Get the test dataloader + test_dataloader = self.get_test_dataloader(test_dataset) + start_time = time.time() + + # Set compute_metrics to None and store it for later use + compute_metrics = self.compute_metrics + self.compute_metrics = None + + # Get the evaluation loop + eval_loop = self.prediction_loop if self.args.use_legacy_prediction_loop else self.evaluation_loop + try: + # Run the evaluation loop + output = eval_loop( + test_dataloader, + description="Prediction", + ignore_keys=ignore_keys, + metric_key_prefix=metric_key_prefix, + ) + finally: + # Reset compute_metrics to its original value + self.compute_metrics = compute_metrics + + # If the test dataset is a datasets.Dataset, set its format + if isinstance(test_dataset, datasets.Dataset): + test_dataset.set_format( + type=test_dataset.format["type"], + columns=list(test_dataset.features.keys()), + ) + + # Postprocess the output and return the predictions + predictions = self.postprocess(output, test_examples, test_dataset, mode=mode) + + # Stop tracking memory usage and update metrics + self._memory_tracker.stop_and_update_metrics(output.metrics) + + return predictions diff --git a/retro_reader/constants.py b/retro_reader/constants.py new file mode 100644 index 0000000000000000000000000000000000000000..8de4237e343061586d7556fa52c074edb9b68234 --- /dev/null +++ b/retro_reader/constants.py @@ -0,0 +1,101 @@ +import os +from datasets import Sequence, Value, Features +from datasets import Dataset, DatasetDict + +EXAMPLE_FEATURES = Features( + { + "guid": Value(dtype="string", id=None), + "question": Value(dtype="string", id=None), + "context": Value(dtype="string", id=None), + "answers": Sequence( + feature={ + "text": Value(dtype="string", id=None), + "answer_start": Value(dtype="int32", id=None), + }, + ), + "is_impossible": Value(dtype="bool", id=None), + "title": Value(dtype="string", id=None), + "classtype": Value(dtype="string", id=None), + "source": Value(dtype="string", id=None), + "dataset": Value(dtype="string", id=None), + } +) + +SKETCH_TRAIN_FEATURES = Features( + { + "input_ids": Sequence(feature=Value(dtype='int32', id=None)), + "attention_mask": Sequence(feature=Value(dtype='int8', id=None)), + "token_type_ids": Sequence(feature=Value(dtype='int8', id=None)), + "labels": Value(dtype='int64', id=None), + } +) + +SKETCH_EVAL_FEATURES = Features( + { + "input_ids": Sequence(feature=Value(dtype='int32', id=None)), + "attention_mask": Sequence(feature=Value(dtype='int8', id=None)), + "token_type_ids": Sequence(feature=Value(dtype='int8', id=None)), + "labels": Value(dtype='int64', id=None), + "example_id": Value(dtype='string', id=None), + } +) + +INTENSIVE_TRAIN_FEATUERS = Features( + { + "input_ids": Sequence(feature=Value(dtype='int32', id=None)), + "attention_mask": Sequence(feature=Value(dtype='int8', id=None)), + "token_type_ids": Sequence(feature=Value(dtype='int8', id=None)), + "start_positions": Value(dtype='int64', id=None), + "end_positions": Value(dtype='int64', id=None), + "is_impossibles": Value(dtype='float64', id=None), + } +) + +INTENSIVE_EVAL_FEATUERS = Features( + { + "input_ids": Sequence(feature=Value(dtype='int32', id=None)), + "attention_mask": Sequence(feature=Value(dtype='int8', id=None)), + "token_type_ids": Sequence(feature=Value(dtype='int8', id=None)), + "offset_mapping": Sequence( + feature=Sequence( + feature=Value(dtype='int64', id=None) + ) + ), + "example_id": Value(dtype='string', id=None), + } +) + +QUESTION_COLUMN_NAME = "question" +CONTEXT_COLUMN_NAME = "context" +ANSWER_COLUMN_NAME = "answers" +ANSWERABLE_COLUMN_NAME = "is_impossible" +ID_COLUMN_NAME = "guid" + +SCORE_EXT_FILE_NAME = "cls_score.json" +INTENSIVE_PRED_FILE_NAME = "predictions.json" +NBEST_PRED_FILE_NAME = "nbest_predictions.json" +SCORE_DIFF_FILE_NAME = "null_odds.json" + +DEFAULT_CONFIG_FILE = os.path.join( + os.path.realpath(__file__), "args/default_config.yaml" +) + +KO_QUERY_HELP_TEXT = "μ§ˆλ¬Έμ„ μž…λ ₯ν•΄μ£Όμ„Έμš”!" +KO_CONTEXT_HELP_TEXT = "λ¬Έλ§₯을 μž…λ ₯ν•΄μ£Όμ„Έμš”!" + +EN_QUERY_HELP_TEXT = "Plz enter your question!" +EN_CONTEXT_HELP_TEXT = "Plz enter your context!" + +KO_EXAMPLE_QUERY = "μ΄μˆœμ‹ μ€ μ–΄λŠ μ‹œλŒ€μ˜ 무신이야?" +KO_EXAMPLE_CONTEXTS = """ +16μ„ΈκΈ° μ‘°μ„ μ˜ λ¬΄μ‹ μœΌλ‘œ, 일본이 쑰선을 μΉ¨κ³΅ν•˜μ—¬ μΌμ–΄λ‚œ μ „μŸμΈ μž„μ§„μ™œλž€ λ‹Ήμ‹œ μ‘°μ„  μˆ˜κ΅°μ„ ν†΅μ†”ν–ˆλ˜ μ œλ…μ΄μž κ΅¬κ΅­μ˜μ›…μ΄λ‹€. + +침랡ꡰ과 κ΅μ „ν•˜μ—¬ 천재적인 ν™œμ•½μƒμ„ 펼치고 쀑앙 지원 없이 μžκΈ‰μžμ‘±μ„ ν•΄λ‚Έ κ΅° μ§€νœ˜κ΄€μ΄μž, νœ˜ν•˜ μΈμ‚¬λ“€μ—κ²Œ 법에 λ”°λ₯Έ 원칙을 μš”κ΅¬ν•˜λ©΄μ„œλ„ λšœλ ·ν•œ 성곡λ₯ κ³Ό 뢀쑱함 μ—†λŠ” 처우λ₯Ό 보μž₯ν•œ 상관, μ§€λ°©κ΄€ μ‹œμ ˆ λ°±μ„±λ“€μ—κ²Œ 선정을 λ² ν’€κ³  μ „μ‹œμ—λ„ 그듀을 μœ„λ¬΄ν•˜κ³  κ΅¬μ œν•œ λͺ©λ―Όκ΄€, κ³ μœ„ κ΄€λ£Œμ™€ μ ‘μ„  및 μΆ•μž¬λ₯Ό κ±°λΆ€ν•˜κ³  곡정과 ꡭ읡, 절제λ₯Ό μ€‘μ‹œν•œ 인격자, μžμ‹ μ΄ κ΄€ν• ν•œ μ§€μ—­μ˜ λ°±μ„±κ³Ό λ³‘μ‚¬μ—κ²Œ 각쒅 사업을 μž₯λ €ν•˜μ—¬ λ§Žμ€ 수효λ₯Ό μ–»μ–΄λ‚Έ ν–‰μ •κ°€, 그리고 왕을 μœ„μ‹œν•œ μ‘°μ •μ˜ ν•λ°•μœΌλ‘œ μ‚¬ν˜•μˆ˜κ°€ λ˜κ±°λ‚˜ ν›„μž„μžμ˜ μ‹€μ±…μœΌλ‘œ ꡰ사·ꡰ선듀을 거의 μƒμ‹€ν•˜κ±°λ‚˜ μ–΄λ¨Έλ‹ˆμ™€ 아듀을 μžƒλŠ” λ“± λ§Žμ€ μˆ˜λ‚œμ„ κ²ͺ고도 λͺ…λŸ‰ ν•΄μ „ 등에 μž„ν•˜λ©° κ΅΄ν•˜μ§€ μ•Šμ€ 철인의 λ©΄λͺ¨κΉŒμ§€ κ°–μΆ° μ‘°μ„  μ€‘κΈ°μ˜ λͺ…μž₯을 λ„˜μ–΄ ν•œκ΅­μ‚¬ 졜고 μœ„μΈμ˜ λ°˜μ—΄κΉŒμ§€ 였λ₯Έ 인물이닀. + +생전뢀터 κ·Έλ₯Ό μ‚¬μ μœΌλ‘œ μ•Œκ³  있던 인근 λ°±μ„±μ΄λ‚˜ κ΅°μ‘Έ, 일뢀 μž₯μˆ˜μ™€ μž¬μƒλ“€λ‘œλΆ€ν„° λ›°μ–΄λ‚œ 인물둜 ν‰κ°€λ°›μ•˜κ³  κ·Έλ ‡μ§€ μ•Šλ”λΌλ„ λͺ…성이 μ œλ²• μžˆμ—ˆμœΌλ©° 전사 μ†Œμ‹μ— λ§Žμ€ 이가 λ‚¨λ…€λ…Έμ†Œλ₯Ό λΆˆλ¬Έν•˜κ³  크게 μŠ¬νΌν–ˆλ‹€κ³  μ „ν•΄μ§„λ‹€. 사후 쑰정은 관직을 μΆ”μ¦ν–ˆκ³  선비듀은 μ°¬μ–‘μ‹œ(θ©©)λ₯Ό μ§€μ—ˆμœΌλ©° 백성듀은 μΆ”λͺ¨λΉ„λ₯Ό μ„Έμš°λŠ” λ“±, μ΄μˆœμ‹ μ€ μ˜€λž˜λ„λ‘ λ§Žμ€ 좔앙을 λ°›μ•„μ™”λ‹€. μ΄λŠ” μΌμ œκ°•μ κΈ°λ₯Ό 거쳐 ν˜„λŒ€μ—λ„ λ§ˆμ°¬κ°€μ§€λ‘œ, μ΄μˆœμ‹ μ€ λŒ€ν•œλ―Όκ΅­ ꡭ민듀이 κ°€μž₯ μ‘΄κ²½ν•˜λŠ” μœ„μΈ 쀑 ν•œ λͺ…μœΌλ‘œ 꼽히며 ν˜„λŒ€ ν•œκ΅­μ—μ„œ μ„±μ›…μ΄λΌλŠ” μ΅œμƒκΈ‰ μˆ˜μ‚¬κ°€ 이름 μ•žμ— 뢙어도 μ–΄λ–€ μ΄μ˜λ„ μ œκΈ°λ°›μ§€ μ•ŠλŠ”, μ„Έμ’…κ³Ό ν•¨κ»˜ ν•œκ΅­μΈμ—κ²Œ κ°€μž₯ μ‚¬λž‘λ°›λŠ” ν•œκ΅­μ‚¬ μ–‘λŒ€ μœ„μΈμ΄λ‹€. κ°€μž₯ μ‘΄κ²½ν•˜λŠ” μœ„μΈμ„ λ¬»λŠ” μ„€λ¬Έμ‘°μ‚¬μ—μ„œλ„ μ„Έμ’…λŒ€μ™•κ³Ό 1, 2μœ„λ₯Ό λ‹€νˆ¬λ©° μΆ©λ¬΄κ³΅μ΄λΌλŠ” μ‹œν˜Έλ„ μ‹€μ œλ‘œλŠ” κΉ€μ‹œλ―Όκ³Ό 같은 μ—¬λŸ¬ μž₯μˆ˜λ“€μ΄ 받은 μ‹œν˜Έμ΄μ§€λ§Œ ν˜„λŒ€ ν•œκ΅­μΈλ“€μ€ μ΄μˆœμ‹  μ „μš© μ‹œν˜Έλ‘œ μΈμ‹ν•œλ‹€. +""".strip() + +EN_EXAMPLE_QUERY = "When did Beyonce start becoming popular?" +EN_EXAMPLE_CONTEXTS = """ +BeyoncΓ© Giselle Knowles-Carter (/biːˈjΙ’nseΙͺ/ bee-YON-say) (born September 4, 1981) is an American singer, songwriter, record producer and actress. Born and raised in Houston, Texas, she performed in various singing and dancing competitions as a child, and rose to fame in the late 1990s as lead singer of R&B girl-group Destiny\'s Child. Managed by her father, Mathew Knowles, the group became one of the world\'s best-selling girl groups of all time. Their hiatus saw the release of BeyoncΓ©\'s debut album, Dangerously in Love (2003), which established her as a solo artist worldwide, earned five Grammy Awards and featured the Billboard Hot 100 number-one singles "Crazy in Love" and "Baby Boy". +""".strip() \ No newline at end of file diff --git a/retro_reader/metrics.py b/retro_reader/metrics.py new file mode 100644 index 0000000000000000000000000000000000000000..202366073e685de2711084c4f02051edc807fb6f --- /dev/null +++ b/retro_reader/metrics.py @@ -0,0 +1,60 @@ +import datasets +import evaluate +from transformers.trainer_utils import EvalPrediction + +accuracy = evaluate.load("accuracy").compute +precision = evaluate.load("precision").compute +recall = evaluate.load("recall").compute +f1 = evaluate.load("f1").compute +squad_v2 = evaluate.load("squad_v2").compute + +# accuracy = datasets.load_metric("accuracy").compute +# precision = datasets.load_metric("precision").compute +# recall = datasets.load_metric("recall").compute +# f1 = datasets.load_metric("f1").compute +# squad_v2 = datasets.load_metric("squad_v2").compute + +def compute_classification_metric(p: EvalPrediction): + """ + Compute classification metrics for a given prediction. + + Args: + p (EvalPrediction): The prediction object. + + Returns: + datasets.Metric: The metric object containing accuracy, precision, + recall, and f1 score. + """ + # Get the predicted class labels and the reference labels + predictions = p.predictions.argmax(axis=1) + references = p.label_ids + + # Initialize the metric object + metric = accuracy(predictions=predictions, references=references) + + # Update the metric with precision, recall, and f1 score + metric.update(precision(predictions=predictions, references=references)) + metric.update(recall(predictions=predictions, references=references)) + metric.update(f1(predictions=predictions, references=references)) + + # Return the metric object + return metric + + +def compute_squad_v2(p: EvalPrediction): + """ + Compute SQuAD v2 metrics for a given prediction. + + Args: + p (EvalPrediction): The prediction object. + + Returns: + datasets.Metric: The metric object containing SQuAD v2 metrics. + """ + # Get the predicted answers and the reference answers + predictions = p.predictions + references = p.label_ids + + # Compute and return the SQuAD v2 metrics + return squad_v2(predictions=predictions, references=references) + diff --git a/retro_reader/models/__init__.py b/retro_reader/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..70def004b82b693d6dfa2d1dd9243d47d2827684 --- /dev/null +++ b/retro_reader/models/__init__.py @@ -0,0 +1,15 @@ +from .modeling_electra import ( + ElectraConfig, + ElectraForSequenceClassification, + ElectraForQuestionAnsweringAVPool +) +from .modeling_distilbert import ( + DistilBertConfig, + DistilBertForSequenceClassification, + DistilBertForQuestionAnsweringAVPool +) +from .modeling_roberta import ( + RobertaConfig, + RobertaForSequenceClassification, + RobertaForQuestionAnsweringAVPool +) \ No newline at end of file diff --git a/retro_reader/models/modeling_albert.py b/retro_reader/models/modeling_albert.py new file mode 100644 index 0000000000000000000000000000000000000000..6bb4175b0ea9d2964191d7f9dec6dd78336d2c13 --- /dev/null +++ b/retro_reader/models/modeling_albert.py @@ -0,0 +1,275 @@ +import torch +from torch import nn +from torch.nn import CrossEntropyLoss + +from transformers import ( + AlbertForSequenceClassification as SeqClassification, + AlbertPreTrainedModel, + AlbertModel, + AlbertConfig +) + +from .modeling_outputs import ( + QuestionAnsweringModelOutput, + QuestionAnsweringNaModelOutput +) + +class AlbertForSequenceClassification(SeqClassification): + model_type = "albert" + +class AlbertForQuestionAnsweringAVPool(AlbertPreTrainedModel): + _keys_to_ignore_on_load_unexpected = [r"pooler"] + model_type = "albert" + + + def __init__(self, config): + super().__init__(config) + self.num_labels = config.num_labels + + # The `has_ans` module is a linear layer with dropout and a linear layer. + # The purpose of this module is to predict whether the question can be + # answered with a "yes" or "no" given the context. It is trained to output + # a probability distribution over the two classes. + # + # In other words, it predicts the probability of the existence of an + # answer given the context. + # + # If the model predicts a high probability of "yes", it means the model + # thinks the question can be answered. If the model predicts a high + # probability of "no", it means the model thinks the question cannot be + # answered. + # + # The output of this module is used in the loss computation to + # encourage the model to output a probability distribution over the two + # classes. + # + # The input to the module is the first word of the sequence (the + # [CLS] token). + # + # The output of the module is a tensor of shape (batch_size, + # num_labels) where each element is a probability. + + # Initialize weights + self.albert = AlbertModel(config) + self.qa_outputs = nn.Linear(config.hidden_size, config.num_labels) + self.has_ans = nn.Sequential( + nn.Dropout(p=config.hidden_dropout_prob), + nn.Linear(config.hidden_size, self.num_labels) + ) + + self.post_init() + + def forward( + self, + input_ids=None, + attention_mask=None, + token_type_ids=None, + position_ids=None, + head_mask=None, + inputs_embeds=None, + start_positions=None, + end_positions=None, + is_impossibles=None, + output_attentions=None, + output_hidden_states=None, + return_dict=None, + ): + return_dict = return_dict if return_dict is not None else self.config.use_return_dict + + + # outputs shape: (loss(optional, returned when labels is provided, else None), logits, hidden states, attentions) + outputs = self.albert( + input_ids=input_ids, + attention_mask=attention_mask, + token_type_ids=token_type_ids, + position_ids=position_ids, + head_mask=head_mask, + inputs_embeds=inputs_embeds, + output_attentions=output_attentions, + output_hidden_states=output_hidden_states, + ) + + # sequence_output shape: (batch_size, sequence_length, hidden_size) + sequence_output = outputs[0] + + # logits shape: (batch_size, sequence_length, 2) + logits = self.qa_outputs(sequence_output) + + # Split logits to start_logits and end_logits + start_logits, end_logits = logits.split(1, dim=-1) + + # Note that we use .contiguous() to ensure that the tensor is stored in a contiguous block of memory + # start_logits shape: (batch_size, sequence_length, 1) + # end_logits shape: (batch_size, sequence_length, 1) + start_logits = start_logits.squeeze(-1).contiguous() + end_logits = end_logits.squeeze(-1).contiguous() + + # Get the index of the first word + first_word = sequence_output[:, 0, :].contiguous() + + has_logits = self.has_ans(first_word) + + total_loss = None + + if (start_positions is not None and + end_positions is not None and + is_impossibles is not None): + # If we are on multi-GPU, split add a dimension + if len(start_positions.size()) > 1: + start_positions = start_positions.squeeze(-1) + if len(end_positions.size() > 1): + end_positions = end_positions.squeeze(-1) + if len(is_impossibles.size()) > 1: + is_impossibles = is_impossibles.squeeze(-1) + + # sometimes the start/end positions are outside our model inputs, we ignore these terms + # clamping the values in the tensor to be within the range of 0 to ignored_index. + # This means that any value less than 0 or greater than or equal to ignored_index will be set to 0. + ignored_index = start_logits.size(1) + start_positions.clamp_(0, ignored_index) + end_positions.clamp_(0, ignored_index) + is_impossibles.clamp_(0, ignored_index) + + loss_fct = CrossEntropyLoss(ignore_index=ignored_index) + start_loss = loss_fct(start_logits, start_positions) + end_loss = loss_fct(end_logits, end_positions) + span_loss = start_loss + end_loss + + # Internal Front Verification (I-FV) + # alpha1 = 1.0, alpha2 = 0.5 + choice_loss = loss_fct(has_logits, is_impossibles.long()) + total_loss = (span_loss + choice_loss) / 3 + + if not return_dict: + output = ( + start_logits, + end_logits, + has_logits, + ) + outputs[2:] # add hidden states and attention if they are here + return ((total_loss,) + output) if total_loss is not None else output + + return QuestionAnsweringNaModelOutput( + loss=total_loss, + start_logits=start_logits, + end_logits=end_logits, + has_logits=has_logits, + hidden_states=outputs.hidden_states, + attentions=outputs.attentions, + ) + + +class AlbertForQuestionAnsweringAVPoolBCEv3(AlbertPreTrainedModel): + _keys_to_ignore_on_load_unexpected = [r"pooler"] + model_type = "albert" + + def __init__(self, config): + super.__init__(config) + self.num_labels = config.num_labels + + self.albert = AlbertModel(config) + self.qa_outputs = nn.Linear(config.hidden_size, config.num_labels) + self.has_ans1 = nn.Sequential( + nn.Dropout(p=config.hidden_dropout_prob), + nn.Linear(config.hidden_size, 2), + ) + self.has_ans2 = nn.Sequential( + nn.Dropout(p=config.hidden_dropout_prob), + nn.Linear(config.hidden_size, 1), + ) + + # Initialize weights + self.post_init() + + + def forward( + self, + input_ids=None, + attention_mask=None, + token_type_ids=None, + position_ids=None, + head_mask=None, + inputs_embeds=None, + start_positions=None, + end_positions=None, + is_impossibles=None, + output_attentions=None, + output_hidden_states=None, + return_dict=None, + ): + return_dict = return_dict if return_dict is not None else self.config.use_return_dict + + outputs = self.albert( + input_ids=input_ids, + attention_mask=attention_mask, + token_type_ids=token_type_ids, + position_ids=position_ids, + head_mask=head_mask, + inputs_embeds=inputs_embeds, + output_attentions=output_attentions, + output_hidden_states=output_hidden_states, + ) + + sequence_output = outputs[0] + + logits = self.qa_outputs(sequence_output) + start_logits, end_logits = logits.split(1, dim=-1) + start_logits = start_logits.squeeze(-1).contiguous() + end_logits = end_logits.squeeze(-1).contiguous() + + first_word = sequence_output[:, 0, :] + + has_logits1 = self.has_ans1(first_word).squeeze(-1) + has_logits2 = self.has_ans2(first_word).squeeze(-1) + + total_loss = None + if ( + start_positions is not None and + end_positions is not None and + is_impossibles is not None + ): + # If we are on multi-GPU, split add a dimension + if len(start_positions.size()) > 1: + start_positions = start_positions.squeeze(-1) + if len(end_positions.size()) > 1: + end_positions = end_positions.squeeze(-1) + if len(is_impossibles.size()) > 1: + is_impossibles = is_impossibles.squeeze(-1) + # sometimes the start/end positions are outside our model inputs, we ignore these terms + ignored_index = start_logits.size(1) + start_positions.clamp_(0, ignored_index) + end_positions.clamp_(0, ignored_index) + is_impossibles.clamp_(0, ignored_index) + is_impossibles = is_impossibles.to( + dtype=next(self.parameters()).dtype) # fp16 compatibility + + loss_fct = CrossEntropyLoss(ignore_index=ignored_index) + start_loss = loss_fct(start_logits, start_positions) + end_loss = loss_fct(end_logits, end_positions) + span_loss = start_loss + end_loss + + # Internal Front Verification (I-FV) + choice_fct = nn.BCEWithLogitsLoss() + mse_loss_fct = nn.MSELoss() + choice_loss1 = loss_fct(has_logits1, is_impossibles.long()) + choice_loss2 = choice_fct(has_logits2, is_impossibles) + choice_loss3 = mse_loss_fct(has_logits2.view(-1), is_impossibles.view(-1)) + choice_loss = choice_loss1 + choice_loss2 + choice_loss3 + + total_loss = (span_loss + choice_loss) / 5 + + if not return_dict: + output = ( + start_logits, + end_logits, + has_logits1, + ) + outputs[2:] # hidden_states, attentions + return ((total_loss,) + output) if total_loss is not None else output + + return QuestionAnsweringNaModelOutput( + loss=total_loss, + start_logits=start_logits, + end_logits=end_logits, + has_logits=has_logits1, + hidden_states=outputs.hidden_states, + attentions=outputs.attentions, + ) \ No newline at end of file diff --git a/retro_reader/models/modeling_distilbert.py b/retro_reader/models/modeling_distilbert.py new file mode 100644 index 0000000000000000000000000000000000000000..d09c105be5c7f385c34387de9f6cea16078c0c86 --- /dev/null +++ b/retro_reader/models/modeling_distilbert.py @@ -0,0 +1,128 @@ +import torch +from torch import nn +from torch.nn import CrossEntropyLoss + +from transformers import ( + DistilBertForSequenceClassification as SeqClassification, + DistilBertPreTrainedModel, + DistilBertModel, + DistilBertConfig, + +) + +from .modeling_outputs import ( + QuestionAnsweringModelOutput, + QuestionAnsweringNaModelOutput, +) + +class DistilBertForSequenceClassification(SeqClassification): + model_type = "distilbert" + +class DistilBertForQuestionAnsweringAVPool(DistilBertPreTrainedModel): + config_class = DistilBertConfig + base_model_prefix = "distilbert" + model_type = "distilbert" + + def __init__(self, config): + # super(DistilBertForQuestionAnsweringAVPool, self).__init__(config) + super().__init__(config) + self.num_labels = config.num_labels + + self.distilbert = DistilBertModel(config) + self.qa_outputs = nn.Linear(config.hidden_size, config.num_labels) + self.has_ans = nn.Sequential( + # nn.Dropout(p=config.hidden_dropout_prob), + nn.Dropout(p=0.2), + nn.Linear(config.hidden_size, 2), + ) + + self.post_init() + def forward( + self, + input_ids=None, + attention_mask=None, + token_type_ids=None, + position_ids=None, + head_mask=None, + inputs_embeds=None, + start_positions=None, + end_positions=None, + is_impossibles=None, + output_attentions=None, + output_hidden_states=None, + return_dict=None, + ): + + return_dict = return_dict if return_dict is not None else self.config.use_return_dict + + # outputs shape: (loss(optional, returned when labels is provided, else None), logits, hidden states, attentions) + discriminator_hidden_states = self.distilbert( + input_ids=input_ids, + attention_mask=attention_mask, + # token_type_ids=token_type_ids, + # position_ids=position_ids, + head_mask=head_mask, + inputs_embeds=inputs_embeds, + output_attentions=output_attentions, + output_hidden_states=output_hidden_states, + ) + + # sequence_output shape: (batch_size, sequence_length, hidden_size) + sequence_output = discriminator_hidden_states[0] + + # For each input, the model outputs a vector of two numbers: the start and end logits. + logits = self.qa_outputs(sequence_output) + start_logits = logits[:, :, 0].squeeze(-1).contiguous() + end_logits = logits[:, :, 1].squeeze(-1).contiguous() + + first_word = sequence_output[:, 0, :] + + has_logits = self.has_ans(first_word) + + total_loss = None + if ( + start_positions is not None + and end_positions is not None + and is_impossibles is not None + ): + # If we are on multi-GPU, split add a dimension + if len(start_positions.size()) > 1: + start_positions = start_positions.squeeze(-1) + if len(end_positions.size()) > 1: + end_positions = end_positions.squeeze(-1) + if len(is_impossibles.size()) > 1: + is_impossibles = is_impossibles.squeeze(-1) + + # sometimes the start/end positions are outside our model inputs, we ignore these terms + ignored_index = start_logits.size(1) + start_positions.clamp_(0, ignored_index).long() + end_positions.clamp_(0, ignored_index).long() + is_impossibles.clamp_(0, ignored_index).long() + + loss_fct = CrossEntropyLoss(ignore_index=ignored_index) + start_loss = loss_fct(start_logits, start_positions) + end_loss = loss_fct(end_logits, end_positions) + span_loss = start_loss + end_loss + + # Internal Front Verification (I-FV) + alpha1 = 1.0 + alpha2 = 0.5 + choice_loss = loss_fct(has_logits, is_impossibles.long()) + total_loss = alpha1 * span_loss + alpha2 * choice_loss + + if not return_dict: + output = ( + start_logits, + end_logits, + has_logits, + ) + discriminator_hidden_states[2:] # add hidden states and attention if they are here + return ((total_loss,) + output) if total_loss is not None else output + + return QuestionAnsweringNaModelOutput( + loss=total_loss, + start_logits=start_logits, + end_logits=end_logits, + has_logits=has_logits, + hidden_states=discriminator_hidden_states.hidden_states, + attentions=discriminator_hidden_states.attentions, + ) \ No newline at end of file diff --git a/retro_reader/models/modeling_electra.py b/retro_reader/models/modeling_electra.py new file mode 100644 index 0000000000000000000000000000000000000000..bc4cab6f47f4b2a6c272ac05232035c8335074eb --- /dev/null +++ b/retro_reader/models/modeling_electra.py @@ -0,0 +1,166 @@ +import torch +from torch import nn +from torch.nn import CrossEntropyLoss + +from transformers import ( + ElectraForSequenceClassification as SeqClassification, + ElectraPreTrainedModel, + ElectraModel, + ElectraConfig +) + +from .modeling_outputs import ( + QuestionAnsweringModelOutput, + QuestionAnsweringNaModelOutput, +) + +class ElectraForSequenceClassification(SeqClassification): + model_type = "electra" + +class ElectraForQuestionAnsweringAVPool(ElectraPreTrainedModel): + config_class = ElectraConfig + base_model_prefix = "electra" + model_type = "electra" + + def __init__(self, config): + super(ElectraForQuestionAnsweringAVPool, self).__init__(config) + self.num_labels = config.num_labels + + self.electra = ElectraModel(config) + self.qa_outputs = nn.Linear(config.hidden_size, config.num_labels) + self.has_ans = nn.Sequential( + nn.Dropout(p=config.hidden_dropout_prob), + nn.Linear(config.hidden_size, 2), + ) + + self.post_init() + def forward( + self, + input_ids=None, + attention_mask=None, + token_type_ids=None, + position_ids=None, + head_mask=None, + inputs_embeds=None, + start_positions=None, + end_positions=None, + is_impossibles=None, + output_attentions=None, + output_hidden_states=None, + return_dict=None, + ): + """ + Forward pass of the model for question answering. + + Args: + input_ids (torch.Tensor, optional): Indices of input sequence tokens in the vocabulary. + Shape: `(batch_size, sequence_length)`. + attention_mask (torch.Tensor, optional): Mask to avoid performing attention on padding token indices. + Shape: `(batch_size, sequence_length)`. + token_type_ids (torch.Tensor, optional): Segment indices to distinguish different sequences in the input. + Shape: `(batch_size, sequence_length)`. + position_ids (torch.Tensor, optional): Indices of positions of each input sequence token in the position embeddings. + Shape: `(batch_size, sequence_length)`. + head_mask (torch.Tensor, optional): Mask to nullify selected heads of self-attention modules. + Shape: `(num_layers, num_heads)`. + inputs_embeds (torch.Tensor, optional): Pretrained embeddings for the input sequence. + Shape: `(batch_size, sequence_length, hidden_size)`. + start_positions (torch.Tensor, optional): Indices of the start position of the answer span in the input sequence. + Shape: `(batch_size,)`. + end_positions (torch.Tensor, optional): Indices of the end position of the answer span in the input sequence. + Shape: `(batch_size,)`. + is_impossibles (torch.Tensor, optional): Boolean tensor indicating whether the answer span is impossible. + Shape: `(batch_size,)`. + output_attentions (bool, optional): Whether to return the attentions weights of all layers. + output_hidden_states (bool, optional): Whether to return the hidden states of all layers. + return_dict (bool, optional): Whether to return a `QuestionAnsweringNaModelOutput` object instead of a tuple. + + Returns: + torch.Tensor or QuestionAnsweringNaModelOutput: If `return_dict` is `False`, returns a tuple of tensors: + - `start_logits`: Logits for start position classification. + - `end_logits`: Logits for end position classification. + - `has_logits`: Logits for choice classification. + - `hidden_states`: Hidden states of all layers. + - `attentions`: Attentions weights of all layers. + If `return_dict` is `True`, returns a `QuestionAnsweringNaModelOutput` object with the following attributes: + - `loss`: Total loss for training. + - `start_logits`: Logits for start position classification. + - `end_logits`: Logits for end position classification. + - `has_logits`: Logits for choice classification. + - `hidden_states`: Hidden states of all layers. + - `attentions`: Attentions weights of all layers. + """ + return_dict = return_dict if return_dict is not None else self.config.use_return_dict + + # outputs shape: (loss(optional, returned when labels is provided, else None), logits, hidden states, attentions) + discriminator_hidden_states = self.electra( + input_ids=input_ids, + attention_mask=attention_mask, + token_type_ids=token_type_ids, + position_ids=position_ids, + head_mask=head_mask, + inputs_embeds=inputs_embeds, + output_attentions=output_attentions, + output_hidden_states=output_hidden_states, + ) + + # sequence_output shape: (batch_size, sequence_length, hidden_size) + sequence_output = discriminator_hidden_states[0] + + # For each input, the model outputs a vector of two numbers: the start and end logits. + logits = self.qa_outputs(sequence_output) + start_logits, end_logits = logits.split(1, dim=-1) + start_logits = start_logits.squeeze(-1).contiguous() + end_logits = end_logits.squeeze(-1).contiguous() + + first_word = sequence_output[:, 0, :] + + has_logits = self.has_ans(first_word) + + total_loss = None + if ( + start_positions is not None + and end_positions is not None + and is_impossibles is not None + ): + # If we are on multi-GPU, split add a dimension + if len(start_positions.size()) > 1: + start_positions = start_positions.squeeze(-1) + if len(end_positions.size()) > 1: + end_positions = end_positions.squeeze(-1) + if len(is_impossibles.size()) > 1: + is_impossibles = is_impossibles.squeeze(-1) + + # sometimes the start/end positions are outside our model inputs, we ignore these terms + ignored_index = start_logits.size(1) + start_positions.clamp_(0, ignored_index) + end_positions.clamp_(0, ignored_index) + is_impossibles.clamp_(0, ignored_index) + + loss_fct = CrossEntropyLoss(ignore_index=ignored_index) + start_loss = loss_fct(start_logits, start_positions) + end_loss = loss_fct(end_logits, end_positions) + span_loss = start_loss + end_loss + + # Internal Front Verification (I-FV) + alpha1 = 1.0 + alpha2 = 0.5 + choice_loss = loss_fct(has_logits, is_impossibles.long()) + total_loss = alpha1 * span_loss + alpha2 * choice_loss + + if not return_dict: + output = ( + start_logits, + end_logits, + has_logits, + ) + discriminator_hidden_states[2:] # add hidden states and attention if they are here + return ((total_loss,) + output) if total_loss is not None else output + + return QuestionAnsweringNaModelOutput( + loss=total_loss, + start_logits=start_logits, + end_logits=end_logits, + has_logits=has_logits, + hidden_states=discriminator_hidden_states.hidden_states, + attentions=discriminator_hidden_states.attentions, + ) \ No newline at end of file diff --git a/retro_reader/models/modeling_outputs.py b/retro_reader/models/modeling_outputs.py new file mode 100644 index 0000000000000000000000000000000000000000..60bdbdbc295acf0057f49f07202f58cefdea1c1f --- /dev/null +++ b/retro_reader/models/modeling_outputs.py @@ -0,0 +1,34 @@ +from typing import Optional, Tuple + +import torch + +from dataclasses import dataclass +from transformers.file_utils import ModelOutput +from transformers.modeling_outputs import QuestionAnsweringModelOutput + +@dataclass +class QuestionAnsweringNaModelOutput(ModelOutput): + """ + Base class for outputs of question answering models. + + Args: + loss (:obj:`torch.FloatTensor`, `optional`): + Loss of the output. + start_logits (:obj:`torch.FloatTensor`): + Span start logits. + end_logits (:obj:`torch.FloatTensor`): + Span end logits. + has_logits (:obj:`torch.FloatTensor`): + Has logits tensor. + hidden_states (:obj:`tuple(torch.FloatTensor)`, `optional`): + Hidden states of the model at the output of each layer plus the initial embedding outputs. + attentions (:obj:`tuple(torch.FloatTensor)`, `optional`): + Attentions weights after the attention softmax, used to compute the weighted average in the self-attention + heads. + """ + loss: Optional[torch.FloatTensor] = None + start_logits: torch.FloatTensor = None + end_logits: torch.FloatTensor = None + has_logits: torch.FloatTensor = None + hidden_states: Optional[Tuple[torch.FloatTensor]] = None + attentions: Optional[Tuple[torch.FloatTensor]] = None diff --git a/retro_reader/models/modeling_roberta.py b/retro_reader/models/modeling_roberta.py new file mode 100644 index 0000000000000000000000000000000000000000..75e2fe71595c14f9d08f79c5dd56fc7d5a1d9d1a --- /dev/null +++ b/retro_reader/models/modeling_roberta.py @@ -0,0 +1,125 @@ +import torch +from torch import nn +from torch.nn import CrossEntropyLoss + +from transformers import ( + RobertaForSequenceClassification as SeqClassification, + RobertaPreTrainedModel, + RobertaModel, + RobertaConfig +) + +from .modeling_outputs import ( + QuestionAnsweringModelOutput, + QuestionAnsweringNaModelOutput, +) + +class RobertaForSequenceClassification(SeqClassification): + model_type = "roberta" + +class RobertaForQuestionAnsweringAVPool(RobertaPreTrainedModel): + config_class = RobertaConfig + base_model_prefix = "roberta" + model_type = "roberta" + + def __init__(self, config): + super(RobertaForQuestionAnsweringAVPool, self).__init__(config) + self.num_labels = config.num_labels + + self.roberta = RobertaModel(config) + self.qa_outputs = nn.Linear(config.hidden_size, config.num_labels) + self.has_ans = nn.Sequential( + nn.Dropout(p=config.hidden_dropout_prob), + nn.Linear(config.hidden_size, 2), + ) + + self.post_init() + def forward( + self, + input_ids=None, + attention_mask=None, + token_type_ids=None, + position_ids=None, + head_mask=None, + inputs_embeds=None, + start_positions=None, + end_positions=None, + is_impossibles=None, + output_attentions=None, + output_hidden_states=None, + return_dict=None, + ): + + return_dict = return_dict if return_dict is not None else self.config.use_return_dict + + # outputs shape: (loss(optional, returned when labels is provided, else None), logits, hidden states, attentions) + discriminator_hidden_states = self.roberta( + input_ids=input_ids, + attention_mask=attention_mask, + token_type_ids=token_type_ids, + position_ids=position_ids, + head_mask=head_mask, + inputs_embeds=inputs_embeds, + output_attentions=output_attentions, + output_hidden_states=output_hidden_states, + ) + + # sequence_output shape: (batch_size, sequence_length, hidden_size) + sequence_output = discriminator_hidden_states[0] + + # For each input, the model outputs a vector of two numbers: the start and end logits. + logits = self.qa_outputs(sequence_output) + start_logits = logits[:, :, 0].squeeze(-1).contiguous() + end_logits = logits[:, :, 1].squeeze(-1).contiguous() + + first_word = sequence_output[:, 0, :] + + has_logits = self.has_ans(first_word) + + total_loss = None + if ( + start_positions is not None + and end_positions is not None + and is_impossibles is not None + ): + # If we are on multi-GPU, split add a dimension + if len(start_positions.size()) > 1: + start_positions = start_positions.squeeze(-1) + if len(end_positions.size()) > 1: + end_positions = end_positions.squeeze(-1) + if len(is_impossibles.size()) > 1: + is_impossibles = is_impossibles.squeeze(-1) + + # sometimes the start/end positions are outside our model inputs, we ignore these terms + ignored_index = start_logits.size(1) + start_positions.clamp_(0, ignored_index) + end_positions.clamp_(0, ignored_index) + is_impossibles.clamp_(0, ignored_index) + + loss_fct = CrossEntropyLoss(ignore_index=ignored_index) + start_loss = loss_fct(start_logits, start_positions) + end_loss = loss_fct(end_logits, end_positions) + span_loss = start_loss + end_loss + + # Internal Front Verification (I-FV) + alpha1 = 1.0 + alpha2 = 0.5 + choice_loss = loss_fct(has_logits, is_impossibles.long()) + total_loss = alpha1 * span_loss + alpha2 * choice_loss + + if not return_dict: + output = ( + start_logits, + end_logits, + has_logits, + ) + discriminator_hidden_states[2:] # add hidden states and attention if they are here + return ((total_loss,) + output) if total_loss is not None else output + + return QuestionAnsweringNaModelOutput( + loss=total_loss, + start_logits=start_logits, + end_logits=end_logits, + has_logits=has_logits, + hidden_states=discriminator_hidden_states.hidden_states, + attentions=discriminator_hidden_states.attentions, + ) \ No newline at end of file diff --git a/retro_reader/preprocess.py b/retro_reader/preprocess.py new file mode 100644 index 0000000000000000000000000000000000000000..534ce1206283f5467155523d485b23adceb4591f --- /dev/null +++ b/retro_reader/preprocess.py @@ -0,0 +1,393 @@ +import numpy as np +from .constants import ( + QUESTION_COLUMN_NAME, + CONTEXT_COLUMN_NAME, + ANSWER_COLUMN_NAME, + ANSWERABLE_COLUMN_NAME, + ID_COLUMN_NAME +) + +def get_sketch_features( + tokenizer, + mode, + data_args +): + """ + Get the features for sketch model. + + Args: + tokenizer (Tokenizer): Tokenizer for tokenizing input examples. + mode (str): Mode of operation ("train", "eval", or "test"). + data_args (dict): Additional arguments for data loading. + + Returns: + tuple: A tuple containing the function for preparing features and a boolean value indicating if labels are required. + """ + + pad_on_right = tokenizer.padding_side == "right" + max_seq_length = min(data_args.max_seq_length, tokenizer.model_max_length) + + def tokenize_fn(examples): + """ + Tokenize input examples. + + Args: + examples (dict): Input examples. + + Returns: + dict: Tokenized examples. + """ + # Tokenize the input examples using the provided tokenizer. + # The tokenizer is configured to truncate sequences to a maximum length. + # The tokenizer also returns the overflowing tokens, offsets mapping, and token type IDs. + # The padding strategy is determined by the data_args.pad_to_max_length parameter. + # tokenized_examples = tokenizer( + # examples[QUESTION_COLUMN_NAME if pad_on_right else CONTEXT_COLUMN_NAME], + # examples[CONTEXT_COLUMN_NAME if pad_on_right else QUESTION_COLUMN_NAME], + # truncation="only_second" if pad_on_right else "only_first", + # truncation=True, + # max_length=max_seq_length, + # stride=data_args.doc_stride, + # return_overflowing_tokens=True, + # return_offsets_mapping=False, + # return_token_type_ids=data_args.return_token_type_ids, + # padding="max_length" if data_args.pad_to_max_length else False, + # ) + + + # Strip leading and trailing whitespaces from questions and contexts + questions = [q.strip() for q in examples[QUESTION_COLUMN_NAME if pad_on_right else CONTEXT_COLUMN_NAME]] + contexts = [c.strip() for c in examples[CONTEXT_COLUMN_NAME if pad_on_right else QUESTION_COLUMN_NAME]] + + # Now, apply the tokenizer + tokenized_examples = tokenizer( + questions, + contexts, + truncation="only_second" if pad_on_right else "only_first", + max_length=max_seq_length, + stride=data_args.doc_stride, + return_overflowing_tokens=True, + return_offsets_mapping=True, + return_token_type_ids=data_args.return_token_type_ids, + padding="max_length" if data_args.pad_to_max_length else False, + ) + + return tokenized_examples + + + + + def prepare_train_features(examples): + """ + Prepare training features by tokenizing the input examples and adding labels. + + Args: + examples (dict): Input examples. + + Returns: + dict: Tokenized and labeled examples. + """ + # Tokenize the input examples using the provided tokenizer. + tokenized_examples = tokenize_fn(examples) + sample_mapping = tokenized_examples.pop("overflow_to_sample_mapping") + + # Add labels to the tokenized examples. + # The label is 0 for answerable and 1 for not answerable. + tokenized_examples["labels"] = [] + for i in range(len(tokenized_examples["input_ids"])): + sample_index = sample_mapping[i] + + # Determine if the example is answerable or not. + is_impossible = examples[ANSWERABLE_COLUMN_NAME][sample_index] + tokenized_examples["labels"].append(1 if is_impossible else 0) + + return tokenized_examples + + + def prepare_eval_features(examples): + """ + Prepare evaluation features by tokenizing the input examples and adding labels. + + Args: + examples (dict): Input examples. + + Returns: + dict: Tokenized and labeled examples. + + """ + # Tokenize the input examples using the provided tokenizer. + tokenized_examples = tokenize_fn(examples) + sample_mapping = tokenized_examples.pop("overflow_to_sample_mapping") + + # Add example ids and labels to the tokenized examples. + tokenized_examples["example_id"] = [] + tokenized_examples["labels"] = [] + + for i in range(len(tokenized_examples["input_ids"])): + # Determine the sample index. + sample_index = sample_mapping[i] + + # Extract the example id. + id_col = examples[ID_COLUMN_NAME][sample_index] + tokenized_examples["example_id"].append(id_col) + + # Determine the label. + # answerable: 0, not answerable: 1. + is_impossible = examples[ANSWERABLE_COLUMN_NAME][sample_index] + tokenized_examples["labels"].append(1 if is_impossible else 0) + + return tokenized_examples + + + def prepare_test_features(examples): + """ + Prepare test features by tokenizing the input examples and adding example ids. + + Args: + examples (dict): Input examples. + + Returns: + dict: Tokenized and labeled examples. + + """ + # Tokenize the input examples using the provided tokenizer. + tokenized_examples = tokenize_fn(examples) + sample_mapping = tokenized_examples.pop("overflow_to_sample_mapping") + + # Add example ids to the tokenized examples. + tokenized_examples["example_id"] = [] + + for i in range(len(tokenized_examples["input_ids"])): + # Determine the sample index. + sample_index = sample_mapping[i] + + # Extract the example id. + id_col = examples[ID_COLUMN_NAME][sample_index] + + # Add the example id to the tokenized examples. + tokenized_examples["example_id"].append(id_col) + + return tokenized_examples + + + if mode == "train": + get_features_fn = prepare_train_features + elif mode == "eval": + get_features_fn = prepare_eval_features + elif mode == "test": + get_features_fn = prepare_test_features + + return get_features_fn, True + +def get_intensive_features( + tokenizer, + mode, + data_args +): + """ + Generate intensive features for training, evaluation, or testing. + + Args: + tokenizer (Tokenizer): The tokenizer used to tokenize the input examples. + mode (str): The mode of operation. Must be one of "train", "eval", or "test". + data_args (DataArguments): The data arguments containing the configuration for tokenization. + + Returns: + tuple: A tuple containing the function to prepare the features and a boolean indicating if the tokenizer is beam-based. + + Raises: + ValueError: If the mode is not one of "train", "eval", or "test". + + """ + pad_on_right = tokenizer.padding_side == "right" + max_seq_length = min(data_args.max_seq_length, tokenizer.model_max_length) + beam_based = data_args.intensive_model_type in ["xlnet", "xlm"] + + def tokenize_fn(examples): + """ + Tokenize input examples. + + Args: + examples (dict): Input examples. + + Returns: + dict: Tokenized examples. + """ + # Tokenize the input examples using the provided tokenizer. + # The tokenizer is configured to truncate sequences to a maximum length. + # The tokenizer also returns the overflowing tokens, offsets mapping, and token type IDs. + # The padding strategy is determined by the data_args.pad_to_max_length parameter. + tokenized_examples = tokenizer( + examples[QUESTION_COLUMN_NAME if pad_on_right else CONTEXT_COLUMN_NAME], + examples[CONTEXT_COLUMN_NAME if pad_on_right else QUESTION_COLUMN_NAME], + truncation="only_second" if pad_on_right else "only_first", + max_length=max_seq_length, + stride=data_args.doc_stride, + return_overflowing_tokens=True, + return_offsets_mapping=True, + return_token_type_ids=data_args.return_token_type_ids, + padding="max_length" if data_args.pad_to_max_length else False, + ) + + return tokenized_examples + + def prepare_train_features(examples): + """ + Prepare training features by tokenizing the input examples and adding labels. + + Args: + examples (dict): Input examples. + + Returns: + dict: Tokenized and labeled examples. + """ + # Tokenize the input examples using the provided tokenizer. + tokenized_examples = tokenize_fn(examples) + sample_mapping = tokenized_examples.pop("overflow_to_sample_mapping") + offset_mapping = tokenized_examples.pop("offset_mapping") + + # Add start positions, end positions, and is_impossibles to the tokenized examples. + tokenized_examples["start_positions"] = [] + tokenized_examples["end_positions"] = [] + tokenized_examples["is_impossibles"] = [] + + if beam_based: + # Add cls_index and p_mask to the tokenized examples if beam_based. + tokenized_examples["cls_index"] = [] + tokenized_examples["p_mask"] = [] + + for i, offsets in enumerate(offset_mapping): + # We will label impossible answers with the index of the CLS token. + # Get the input_ids and cls_index for the current example. + input_ids = tokenized_examples["input_ids"][i] + cls_index = input_ids.index(tokenizer.cls_token_id) + + # Get the sequence_ids for the current example. + sequence_ids = tokenized_examples.sequence_ids(i) + context_index = 1 if pad_on_right else 0 + + # Build the p_mask: non special tokens and context gets 0.0, the others get 1.0. + # The cls token gets 0.0 too (for predictions of empty answers). + # Inspired by XLNet. + if beam_based: + tokenized_examples["cls_index"].append(cls_index) + tokenized_examples["p_mask"].append( + [ + 0.0 if s == context_index or k == cls_index else 1.0 + for s, k in enumerate(sequence_ids) + ] + ) + + # Get the sample_index, answers, and is_impossible for the current example. + sample_index = sample_mapping[i] + answers = examples[ANSWER_COLUMN_NAME][sample_index] + is_impossible = examples[ANSWERABLE_COLUMN_NAME][sample_index] + + # If no answers are given, set the cls_index as answer. + if is_impossible or len(answers["answer_start"]) == 0: + tokenized_examples["start_positions"].append(cls_index) + tokenized_examples["end_positions"].append(cls_index) + tokenized_examples["is_impossibles"].append(1.0) # unanswerable + else: + # Start and end token index of the current span in the text. + start_char = answers["answer_start"][0] + end_char = start_char + len(answers["text"][0]) + + # sequence_ids: 0 for question, 1 for context, None for others + + # Start token index of the current span in the tokenized context. + token_start_index = 0 + while sequence_ids[token_start_index] != context_index: + token_start_index += 1 + + # End token index of the current span in the tokenized context. + token_end_index = len(input_ids) - 1 + while sequence_ids[token_end_index] != context_index: + token_end_index -= 1 + + # Detect if the answer is out of the span (in which case this feature is labeled with the CLS index). + if not (offsets[token_start_index][0] <= start_char and + offsets[token_end_index][1] >= end_char + ): + tokenized_examples["start_positions"].append(cls_index) + tokenized_examples["end_positions"].append(cls_index) + tokenized_examples["is_impossibles"].append(1.0) # answerable + else: + # Otherwise move the token_start_index and token_end_index to the two ends of the answer. + # Note: we could go after the last offset if the answer is the last word (edge case). + while (token_start_index < len(offsets) and + offsets[token_start_index][0] <= start_char): + token_start_index += 1 + tokenized_examples["start_positions"].append(token_start_index - 1) + + while offsets[token_end_index][1] >= end_char: + token_end_index -= 1 + tokenized_examples["end_positions"].append(token_end_index + 1) + tokenized_examples["is_impossibles"].append(0.0) # answerable + + return tokenized_examples + + + def prepare_eval_features(examples): + """ + Prepare evaluation features by tokenizing the input examples and adding labels. + + Args: + examples (dict): Input examples. + + Returns: + dict: Tokenized and labeled examples. + """ + # Tokenize the input examples using the provided tokenizer. + tokenized_examples = tokenize_fn(examples) + sample_mapping = tokenized_examples.pop("overflow_to_sample_mapping") + + # Add example ids to the tokenized examples. + tokenized_examples["example_id"] = [] + + if beam_based: + # Add cls_index and p_mask to the tokenized examples if beam_based. + tokenized_examples["cls_index"] = [] + tokenized_examples["p_mask"] = [] + + for i, input_ids in enumerate(tokenized_examples["input_ids"]): + # Find the CLS index in the input_ids. + cls_index = input_ids.index(tokenizer.cls_token_id) + + sequence_ids = tokenized_examples.sequence_ids(i) + context_index = 1 if pad_on_right else 0 + + if beam_based: + # Build the p_mask: non special tokens and context gets 0.0, the others get 1.0. + # The cls token gets 0.0 too (for predictions of empty answers). + # Inspired by XLNet. + tokenized_examples["cls_index"].append(cls_index) + tokenized_examples["p_mask"].append( + [ + 0.0 if s == context_index or k == cls_index else 1.0 + for s, k in enumerate(sequence_ids) + ] + ) + + sample_index = sample_mapping[i] + id_col = examples[ID_COLUMN_NAME][sample_index] + tokenized_examples["example_id"].append(id_col) + + # Set to None the offset mapping that are not part of the context + # so it's easy to determine if a token position is part of the context or not. + tokenized_examples["offset_mapping"][i] = [ + (o if sequence_ids[k] == context_index else None) + for k, o in enumerate(tokenized_examples["offset_mapping"][i]) + ] + + return tokenized_examples + + if mode == "train": + get_features_fn = prepare_train_features + elif mode == "eval": + get_features_fn = prepare_eval_features + elif mode == "test": + get_features_fn = prepare_eval_features + + return get_features_fn, True + diff --git a/retro_reader/retro_reader.py b/retro_reader/retro_reader.py new file mode 100644 index 0000000000000000000000000000000000000000..8cf00e4fb26f9dae159ca209c64ab2b64ff312b6 --- /dev/null +++ b/retro_reader/retro_reader.py @@ -0,0 +1,904 @@ +import os +import time +import json +import math +import copy +import collections +from typing import Optional, List, Dict, Tuple, Callable, Any, Union, NewType +import numpy as np +from tqdm import tqdm + +import datasets + + +from transformers import AutoTokenizer +from transformers.tokenization_utils_fast import PreTrainedTokenizerFast +from transformers.utils import logging +from transformers.trainer_utils import EvalPrediction, EvalLoopOutput + +from .args import ( + HfArgumentParser, + RetroArguments, + TrainingArguments, +) + +from .base import BaseReader +from . import constants as C +from .preprocess import ( + get_sketch_features, + get_intensive_features +) +from .metrics import ( + compute_classification_metric, + compute_squad_v2 +) + +DataClassType = NewType("DataClassType", Any) +logger = logging.get_logger(__name__) + +class SketchReader(BaseReader): + name: str = "sketch" + + def postprocess( + self, + output: Union[np.ndarray, EvalLoopOutput], + eval_examples: datasets.Dataset, + eval_dataset: datasets.Dataset, + mode: str = "evaluate", + ) -> Union[EvalPrediction, Dict[str, float]]: + """ + Postprocess the output of the SketchReader model. + + Args: + output (Union[np.ndarray, EvalLoopOutput]): The model output. + eval_examples (datasets.Dataset): The evaluation examples. + eval_dataset (datasets.Dataset): The evaluation dataset. + mode (str, optional): The mode of operation. Defaults to "evaluate". + + Returns: + Union[EvalPrediction, Dict[str, float]]: The evaluation prediction or the final map. + """ + + # External Front Verification (E-FV) + + # Extract the logits from the output + if isinstance(output, EvalLoopOutput): + logits = output.predictions + else: + logits = output + + # Create a mapping from example ID to index + example_id_to_index = {k: i for i, k in enumerate(eval_examples[C.ID_COLUMN_NAME])} + + # Create a mapping from example index to features + features_per_example = collections.defaultdict(list) + for i, feature in enumerate(eval_dataset): + features_per_example[example_id_to_index[feature["example_id"]]].append(i) # example_id added from get_sketch_features + + # Create a mapping from example index to the number of features + count_map = {k: len(v) for k, v in features_per_example.items()} + + # Calculate the average logits for each example + logits_ans = np.zeros(len(count_map)) + logits_na = np.zeros(len(count_map)) + for example_index, example in enumerate(tqdm(eval_examples)): + feature_indices = features_per_example[example_index] + n_strides = count_map[example_index] + logits_ans[example_index] += logits[example_index, 0] / n_strides + logits_na[example_index] += logits[example_index, 1] / n_strides + + # Calculate the E-VF score + score_ext = logits_ans - logits_na + + # Save the EVF score + final_map = dict(zip(eval_examples[C.ID_COLUMN_NAME], score_ext.tolist())) + with open(os.path.join(self.args.output_dir, C.SCORE_EXT_FILE_NAME), "w") as writer: + writer.write(json.dumps(final_map, indent=4) + "\n") + + if mode == "evaluate": + return EvalPrediction( + predictions=logits, label_ids=output.label_ids, + ) + else: + return final_map + +class IntensiveReader(BaseReader): + name: str = "intensive" + + def postprocess( + self, + output: EvalLoopOutput, + eval_examples: datasets.Dataset, + eval_dataset: datasets.Dataset, + log_level: int = logging.WARNING, + mode: str = "evaluate", + ) -> Union[List[Dict[str, Any]], EvalPrediction]: + """ + Post-processing step for the internal front verification (I-FV) and formatting the results. + + Args: + output (EvalLoopOutput): The output of the model's evaluation loop. + eval_examples (datasets.Dataset): The evaluation examples. + eval_dataset (datasets.Dataset): The evaluation dataset. + log_level (int, optional): The logging level. Defaults to logging.WARNING. + mode (str, optional): The mode of the post-processing. Defaults to "evaluate". + + Returns: + Union[List[Dict[str, Any]], EvalPrediction]: The formatted predictions or the evaluation prediction. + """ + # Compute predictions + predictions, nbest_json, scores_diff_json = self.compute_predictions( + eval_examples, + eval_dataset, + output.predictions, + version_2_with_negative=self.data_args.version_2_with_negative, + n_best_size=self.data_args.n_best_size, + max_answer_length=self.data_args.max_answer_length, + null_score_diff_threshold=self.data_args.null_score_diff_threshold, + output_dir=self.args.output_dir, + log_level=log_level, + n_tops=(self.data_args.start_n_top, self.data_args.end_n_top), + ) + + # Return the nbest_json and scores_diff_json if in retro_inference mode + if mode == "retro_inference": + return nbest_json, scores_diff_json + + # Format the predictions + if self.data_args.version_2_with_negative: + formatted_predictions = [ + { + "id": k, + "prediction_text": v, + "no_answer_probability": scores_diff_json[k], + } + for k, v in predictions.items() + ] + else: + formatted_predictions = [ + {"id": k, "prediction_text": v} for k, v in predictions.items() + ] + + # Return the formatted predictions if in predict mode + if mode == "predict": + return formatted_predictions + + # Format the evaluation predictions + references = [ + {"id": ex[C.ID_COLUMN_NAME], "answers": ex[C.ANSWER_COLUMN_NAME]} + for ex in eval_examples + ] + return EvalPrediction( + predictions=formatted_predictions, label_ids=references + ) + + def compute_predictions( + self, + examples: datasets.Dataset, + features: datasets.Dataset, + predictions: Tuple[np.ndarray, np.ndarray], + version_2_with_negative: bool = False, + n_best_size: int = 20, + max_answer_length: int = 30, + null_score_diff_threshold: float = 0.0, + output_dir: Optional[str] = None, + log_level: Optional[int] = logging.WARNING, + n_tops: Tuple[int, int] = (-1, -1), + use_choice_logits: bool = False, + ): + """ + Compute predictions for a given set of examples based on the provided features and model predictions. + + Args: + examples (datasets.Dataset): The dataset containing the examples. + features (datasets.Dataset): The dataset containing the features. + predictions (Tuple[np.ndarray, np.ndarray]): A tuple containing the start logits, end logits, and choice logits. + version_2_with_negative (bool, optional): Whether to use version 2 with negative predictions. Defaults to False. + n_best_size (int, optional): The number of top predictions to consider. Defaults to 20. + max_answer_length (int, optional): The maximum length of the answer. Defaults to 30. + null_score_diff_threshold (float, optional): The score difference threshold for the null prediction. Defaults to 0.0. + output_dir (Optional[str], optional): The directory to save the predictions. Defaults to None. + log_level (Optional[int], optional): The log level. Defaults to logging.WARNING. + n_tops (Tuple[int, int], optional): The number of top predictions to consider for each example. Defaults to (-1, -1). + use_choice_logits (bool, optional): Whether to use choice logits. Defaults to False. + + Returns: + Tuple[Dict[str, str], Dict[str, List[Dict[str, Union[str, float]]]], Dict[str, float]]: A tuple containing the all predictions, all n-best predictions, and scores difference. + + Raises: + ValueError: If the length of predictions is not 2 or 3. + """ + if len(predictions) not in [2, 3]: + raise ValueError( + "`predictions` should be a tuple with two elements (start_logits, end_logits) or three elements (start_logits, end_logits, choice_logits)." + ) + + # if len(predictions) == 3: + # all_start_logits, all_end_logits, all_choice_logits = predictions + # else: + # all_start_logits, all_end_logits = predictions + # all_choice_logits = None + + all_start_logits, all_end_logits = predictions[:2] + all_choice_logits = None + if len(predictions) == 3: + all_choice_logits = predictions[-1] + + # all_choice_logits = predictions[2] if len(predictions) == 3 else None + + # Build a map example to its corresponding features. + example_id_to_index = {k: i for i, k in enumerate(examples[C.ID_COLUMN_NAME])} + features_per_example = collections.defaultdict(list) + for i, feature in enumerate(features): + features_per_example[example_id_to_index[feature["example_id"]]].append(i) + + all_predictions = collections.OrderedDict() + all_nbest_json = collections.OrderedDict() + scores_diff_json = collections.OrderedDict() if version_2_with_negative else None + + # Logging. + logger.setLevel(log_level) + logger.info(f"Post-processing {len(examples)} example predictions split into {len(features)} features.") + + # Looping through all the examples + for example_index, example in enumerate(tqdm(examples)): + # Those are the indices of the features associated to the current example. + feature_indices = features_per_example[example_index] + + min_null_prediction = None + prelim_predictions = [] + + # Looping through all the features associated to the current example. + for feature_index in feature_indices: + # We grab the predictions of the model for this feature. + start_logits = all_start_logits[feature_index] + end_logits = all_end_logits[feature_index] + + feature_null_score = start_logits[0] + end_logits[0] + if all_choice_logits is not None: + choice_logits = all_choice_logits[feature_index] + if use_choice_logits: + feature_null_score = choice_logits[1] + + # This is what will allow us to map some the positions + # in our logits to span of texts in the original context. + offset_mapping = features[feature_index]["offset_mapping"] + + # Optional `token_is_max_context`, + # if provided we will remove answers that do not have the maximum context + # available in the current feature. + token_is_max_context = features[feature_index].get("token_is_max_context", None) + + # Update minimum null prediction + if ( + min_null_prediction is None or + min_null_prediction["score"] > feature_null_score + ): + min_null_prediction = { + "offsets": (0, 0), + "score": feature_null_score, + "start_logit": start_logits[0], + "end_logit": end_logits[0], + } + + # Go through all possibilities for the {top k} greater start and end logits + start_indexes = np.argsort(start_logits)[-1 : -n_best_size - 1 : -1].tolist() + end_indexes = np.argsort(end_logits)[-1 : -n_best_size - 1 : -1].tolist() + for start_index in start_indexes: + for end_index in end_indexes: + # We could hypothetically create invalid predictions, e.g., predict + # that the start of the span is in the question. We throw out all + # invalid predictions. + if ( + start_index >= len(offset_mapping) or + end_index >= len(offset_mapping) or + offset_mapping[start_index] is None or + offset_mapping[end_index] is None + ): + continue + # Don't consider answers with a length that is either < 0 or > max_answer_length. + if ( + end_index < start_index or + end_index - start_index + 1 > max_answer_length + ): + continue + # Don't consider answer that don't have the maximum context available + if ( + token_is_max_context is not None and + not token_is_max_context.get(str(start_index), False) + ): + continue + + prelim_predictions.append( + { + "offsets": (offset_mapping[start_index][0], offset_mapping[end_index][1]), + "score": start_logits[start_index] + end_logits[end_index], + "start_logit": start_logits[start_index], + "end_logit": end_logits[end_index], + } + ) + + if version_2_with_negative: + # Add the minimum null prediction + prelim_predictions.append(min_null_prediction) + null_score = min_null_prediction["score"] + + # Only keep the best `n_best_size` predictions. + predictions = sorted(prelim_predictions, key=lambda x: x["score"], reverse=True)[:n_best_size] + + # Add back the minimum null prediction if it was removed because of its low score + if version_2_with_negative and not any(p["offsets"] == (0, 0) for p in predictions): + predictions.append(min_null_prediction) + + # Use the offsets to gather the answer text in the original context + context = example["context"] + for pred in predictions: + offsets = pred.pop("offsets") + pred["text"] = context[offsets[0] : offsets[1]] + + # In the very rare edge case we have not a single non-null prediction, + # we create a fake prediction to avoid failure. + if len(predictions) == 0 or (len(predictions) == 1 and predictions[0]["text"] == ""): + predictions.insert(0, {"text": "", "start_logit": 0.0, "end_logit": 0.0, "score": 0.0,}) + + # Compute the softmax of all scores + # (we do it with numpy to stay independent from torch/tf) in this file, + # using the LogSum trick). + scores = np.array([pred.pop("score") for pred in predictions]) + exp_scores = np.exp(scores - np.max(scores)) + probs = exp_scores / exp_scores.sum() + + # Include the probabilities in our predictions. + for prob, pred in zip(probs, predictions): + pred["probability"] = prob + + # Pick the best prediction. If the null answer is not possible, this is easy. + if not version_2_with_negative: + all_predictions[example[C.ID_COLUMN_NAME]] = predictions[0]["text"] + else: + # Otherwise we first need to find the best non-empty prediction. + i = 0 + try: + while predictions[i]["text"] == "": + i += 1 + except: + i = 0 + best_non_null_pred = predictions[i] + + # Then we compare to the null prediction using the threshold. + score_diff = null_score - best_non_null_pred["start_logit"] - best_non_null_pred["end_logit"] + scores_diff_json[example[C.ID_COLUMN_NAME]] = float(score_diff) # To be JSON-serializable. + if score_diff > null_score_diff_threshold: + all_predictions[example[C.ID_COLUMN_NAME]] = "" + else: + all_predictions[example[C.ID_COLUMN_NAME]] = best_non_null_pred["text"] + + # Make `predictions` JSON-serializable by casting np.float back to float. + all_nbest_json[example[C.ID_COLUMN_NAME]] = [ + {k: (float(v) if isinstance(v, (np.float16, np.float32, np.float64)) else v) for k, v in pred.items()} + for pred in predictions + ] + + # If we have an output_dir, let's save all those dicts. + if output_dir is not None: + if not os.path.isdir(output_dir): + raise EnvironmentError(f"{output_dir} is not a directory.") + + prediction_file = os.path.join(output_dir, C.INTENSIVE_PRED_FILE_NAME) + nbest_file = os.path.join(output_dir, C.NBEST_PRED_FILE_NAME) + if version_2_with_negative: + null_odds_file = os.path.join(output_dir, C.SCORE_DIFF_FILE_NAME) + + logger.info(f"Saving predictions to {prediction_file}.") + with open(prediction_file, "w") as writer: + writer.write(json.dumps(all_predictions, indent=4) + "\n") + logger.info(f"Saving nbest_preds to {nbest_file}.") + with open(nbest_file, "w") as writer: + writer.write(json.dumps(all_nbest_json, indent=4) + "\n") + if version_2_with_negative: + logger.info(f"Saving null_odds to {null_odds_file}.") + with open(null_odds_file, "w") as writer: + writer.write(json.dumps(scores_diff_json, indent=4) + "\n") + + return all_predictions, all_nbest_json, scores_diff_json + +class RearVerifier: + + def __init__( + self, + beta1: int = 1, + beta2: int = 1, + best_cof: int = 1, + thresh: float = 0.0, + ): + self.beta1 = beta1 + self.beta2 = beta2 + self.best_cof = best_cof + self.thresh = thresh + + def __call__( + self, + score_ext: Dict[str, float], + score_diff: Dict[str, float], + nbest_preds: Dict[str, Dict[int, Dict[str, float]]] + ): + """ + This function takes in the score_ext and score_diff dictionaries, and the nbest_preds dictionary. + It performs a verification process on the input data and returns the output predictions and scores. + + Args: + score_ext (Dict[str, float]): A dictionary containing the extended scores. + score_diff (Dict[str, float]): A dictionary containing the score differences. + nbest_preds (Dict[str, Dict[int, Dict[str, float]]]): A dictionary containing the nbest predictions. + + Returns: + Tuple[Dict[str, str], Dict[str, float]]: A tuple containing the output predictions and scores. + """ + # Initialize an ordered dictionary to store all the scores + all_scores = collections.OrderedDict() + # Check if the keys of score_ext and score_diff are equal + assert score_ext.keys() == score_diff.keys() + # Iterate over the keys in score_ext and calculate the scores + for key in score_ext.keys(): + if key not in all_scores: + all_scores[key] = [] + all_scores[key].extend( + [self.beta1 * score_ext[key], + self.beta2 * score_diff[key]] + ) + # Calculate the mean score for each key and store it in output_scores + output_scores = {} + for key, scores in all_scores.items(): + mean_score = sum(scores) / float(len(scores)) + output_scores[key] = mean_score + + # Initialize an ordered dictionary to store all the nbest predictions + all_nbest = collections.OrderedDict() + # Iterate over the keys in nbest_preds and calculate the nbest predictions + for key, entries in nbest_preds.items(): + if key not in all_nbest: + all_nbest[key] = collections.defaultdict(float) + for entry in entries: + prob = self.best_cof * entry["probability"] + all_nbest[key][entry["text"]] += prob + # # Sort the nbest predictions for each key based on the probability and store the best text in output_predictions + # output_predictions = {key: sorted(entry_map.keys(), key=lambda x: entry_map[x], reverse=True)[0] for key, entry_map in all_nbest.items()} + + # # If the score for a question is above the threshold, set the prediction to empty string + # output_predictions = {qid: "" if output_scores[qid] > self.thresh else output_predictions[qid] for qid in output_predictions.keys()} + + # Sort the nbest predictions for each key based on the probability and store the best text in output_predictions + output_predictions = {} + for key, entry_map in all_nbest.items(): + sorted_texts = sorted( + entry_map.keys(), key=lambda x: entry_map[x], reverse=True + ) + best_text = sorted_texts[0] + output_predictions[key] = best_text + + # If the score for a question is above the threshold, set the prediction to empty string + for qid in output_predictions.keys(): + if output_scores[qid] > self.thresh: + output_predictions[qid] = "" + + return output_predictions, output_scores + + +class RetroReader: + def __init__( + self, + args, + sketch_reader: SketchReader, + intensive_reader: IntensiveReader, + rear_verifier: RearVerifier, + prep_fn: Tuple[Callable, Callable], + ): + self.args = args + # Set submodules + self.sketch_reader = sketch_reader + self.intensive_reader = intensive_reader + self.rear_verifier = rear_verifier + + # Set prep function for inference + self.sketch_prep_fn, self.intensive_prep_fn = prep_fn + + @classmethod + def load( + cls, + train_examples=None, + sketch_train_dataset=None, + intensive_train_dataset=None, + eval_examples=None, + sketch_eval_dataset=None, + intensive_eval_dataset=None, + config_file: str = C.DEFAULT_CONFIG_FILE, + device: str = "cpu", + ): + # Get arguments from yaml files + parser = HfArgumentParser([RetroArguments, TrainingArguments]) + retro_args, training_args = parser.parse_yaml_file(yaml_file=config_file) + if training_args.run_name is not None and "," in training_args.run_name: + sketch_run_name, intensive_run_name = training_args.run_name.split(",") + else: + sketch_run_name, intensive_run_name = None, None + if training_args.metric_for_best_model is not None and "," in training_args.metric_for_best_model: + sketch_best_metric, intensive_best_metric = training_args.metric_for_best_model.split(",") + else: + sketch_best_metric, intensive_best_metric = None, None + sketch_training_args = copy.deepcopy(training_args) + intensive_training_args = training_args + + print(f"Loading sketch tokenizer from {retro_args.sketch_tokenizer_name} ...") + sketch_tokenizer = AutoTokenizer.from_pretrained( + # pretrained_model_name_or_path="google/electra-large-discriminator", + pretrained_model_name_or_path=retro_args.sketch_tokenizer_name, + use_auth_token=retro_args.use_auth_token, + revision=retro_args.sketch_revision, + # return_tensors='pt', + ) + # sketch_tokenizer.to(device) + + # If `train_examples` is feeded, perform preprocessing + if train_examples is not None and sketch_train_dataset is None: + print("[Sketch] Preprocessing train examples ...") + sketch_prep_fn, is_batched = get_sketch_features(sketch_tokenizer, "train", retro_args) + sketch_train_dataset = train_examples.map( + sketch_prep_fn, + batched=is_batched, + remove_columns=train_examples.column_names, + num_proc=retro_args.preprocessing_num_workers, + load_from_cache_file=not retro_args.overwrite_cache, + ) + # If `eval_examples` is feeded, perform preprocessing + if eval_examples is not None and sketch_eval_dataset is None: + print("[Sketch] Preprocessing eval examples ...") + sketch_prep_fn, is_batched = get_sketch_features(sketch_tokenizer, "eval", retro_args) + sketch_eval_dataset = eval_examples.map( + sketch_prep_fn, + batched=is_batched, + remove_columns=eval_examples.column_names, + num_proc=retro_args.preprocessing_num_workers, + load_from_cache_file=not retro_args.overwrite_cache, + ) + # Get preprocessing function for inference + print("[Sketch] Preprocessing inference examples ...") + sketch_prep_fn, _ = get_sketch_features(sketch_tokenizer, "test", retro_args) + + # Get model for sketch reader + sketch_model_cls = retro_args.sketch_model_cls + print(f"[Sketch] Loading sketch model from {retro_args.sketch_model_name} ...") + sketch_model = sketch_model_cls.from_pretrained( + pretrained_model_name_or_path=retro_args.sketch_model_name, + use_auth_token=retro_args.use_auth_token, + revision=retro_args.sketch_revision, + ) + sketch_model.to(device) + + # # Free sketch weights for transfer learning + # if retro_args.sketch_model_mode == "finetune": + # pass + # else: + # print("[Sketch] Freezing sketch weights for transfer learning ...") + # for param in list(sketch_model.parameters())[:-5]: + # param.requires_grad_(False) + + # Get sketch reader + sketch_training_args.run_name = sketch_run_name + sketch_training_args.output_dir += "/sketch" + sketch_training_args.metric_for_best_model = sketch_best_metric + sketch_reader = SketchReader( + model=sketch_model, + args=sketch_training_args, + train_dataset=sketch_train_dataset, + eval_dataset=sketch_eval_dataset, + eval_examples=eval_examples, + data_args=retro_args, + tokenizer=sketch_tokenizer, + compute_metrics=compute_classification_metric, + ) + + print(f"[Intensive] Loading intensive tokenizer from {retro_args.intensive_tokenizer_name} ...") + intensive_tokenizer = AutoTokenizer.from_pretrained( + pretrained_model_name_or_path=retro_args.intensive_tokenizer_name, + use_auth_token=retro_args.use_auth_token, + revision=retro_args.intensive_revision, + # return_tensors='pt', + ) + # intensive_tokenizer.to(device) + + # If `train_examples` is feeded, perform preprocessing + if train_examples is not None and intensive_train_dataset is None: + print("[Intensive] Preprocessing train examples ...") + intensive_prep_fn, is_batched = get_intensive_features(intensive_tokenizer, "train", retro_args) + intensive_train_dataset = train_examples.map( + intensive_prep_fn, + batched=is_batched, + remove_columns=train_examples.column_names, + num_proc=retro_args.preprocessing_num_workers, + load_from_cache_file=not retro_args.overwrite_cache, + ) + # If `eval_examples` is feeded, perform preprocessing + if eval_examples is not None and intensive_eval_dataset is None: + print("[Intensive] Preprocessing eval examples ...") + intensive_prep_fn, is_batched = get_intensive_features(intensive_tokenizer, "eval", retro_args) + intensive_eval_dataset = eval_examples.map( + intensive_prep_fn, + batched=is_batched, + remove_columns=eval_examples.column_names, + num_proc=retro_args.preprocessing_num_workers, + load_from_cache_file=not retro_args.overwrite_cache, + ) + # Get preprocessing function for inference + print("[Intensive] Preprocessing test examples ...") + intensive_prep_fn, _ = get_intensive_features(intensive_tokenizer, "test", retro_args) + + # Get model for intensive reader + intensive_model_cls = retro_args.intensive_model_cls + print(f"[Intensive] Loading intensive model from {retro_args.intensive_model_name} ...") + intensive_model = intensive_model_cls.from_pretrained( + pretrained_model_name_or_path=retro_args.intensive_model_name, + use_auth_token=retro_args.use_auth_token, + revision=retro_args.intensive_revision, + ) + intensive_model.to(device) + + # Free intensive weights for transfer learning + if retro_args.intensive_model_mode == "finetune": + pass + else: + print("[Intensive] Freezing intensive weights for transfer learning ...") + for param in list(intensive_model.parameters())[:-5]: + param.requires_grad_(False) + + # Get intensive reader + intensive_training_args.run_name = intensive_run_name + intensive_training_args.output_dir += "/intensive" + intensive_training_args.metric_for_best_model = intensive_best_metric + intensive_reader = IntensiveReader( + model=intensive_model, + args=intensive_training_args, + train_dataset=intensive_train_dataset, + eval_dataset=intensive_eval_dataset, + eval_examples=eval_examples, + data_args=retro_args, + tokenizer=intensive_tokenizer, + compute_metrics=compute_squad_v2, + ) + + # Get rear verifier + rear_verifier = RearVerifier( + beta1=retro_args.beta1, + beta2=retro_args.beta2, + best_cof=retro_args.best_cof, + thresh=retro_args.rear_threshold, + ) + + return cls( + args=retro_args, + sketch_reader=sketch_reader, + intensive_reader=intensive_reader, + rear_verifier=rear_verifier, + prep_fn=(sketch_prep_fn, intensive_prep_fn), + ) + + def __call__( + self, + query: str, + context: Union[str, List[str]], + return_submodule_outputs: bool = False, + ) -> Tuple[Any]: + """ + Performs inference on a given query and context. + + Args: + query (str): The query to be answered. + context (Union[str, List[str]]): The context in which the query is asked. + If it is a list of strings, they will be joined together. + return_submodule_outputs (bool, optional): Whether to return the outputs of the submodules. + Defaults to False. + + Returns: + Tuple[Any]: A tuple containing the predictions, scores, and optionally the outputs of the submodules. + """ + # If context is a list, join it into a single string + if isinstance(context, list): + context = " ".join(context) + + # Create a predict examples dataset with a single example + predict_examples = datasets.Dataset.from_dict({ + "example_id": ["0"], # Example ID + C.ID_COLUMN_NAME: ["id-01"], # ID + C.QUESTION_COLUMN_NAME: [query], # Query + C.CONTEXT_COLUMN_NAME: [context], # Context + }) + + # Perform inference on the predict examples dataset + return self.inference(predict_examples, return_submodule_outputs=return_submodule_outputs) + + def train(self, module: str = "all", device: str = "cpu"): + """ + Trains the specified module. + + Args: + module (str, optional): The module to train. Defaults to "all". + Possible values: "all", "sketch", "intensive". + """ + + def wandb_finish(module): + """ + Finishes the Weights & Biases (wandb) run for the given module. + + Args: + module: The module for which to finish the wandb run. + """ + for callback in module.callback_handler.callbacks: + # Check if the callback is a wandb callback + if "wandb" in str(type(callback)).lower(): + # Finish the wandb run + if hasattr(callback, '_wandb'): + callback._wandb.finish() + # Reset the initialized flag + callback._initialized = False + + print(f"Starting training for module: {module}") + # Train sketch reader + if module.lower() in ["all", "sketch"]: + print("Training sketch reader") + self.sketch_reader.train() + + print("Saving sketch reader") + self.sketch_reader.save_model() + print("Saving sketch reader state") + self.sketch_reader.save_state() + + self.sketch_reader.free_memory() + wandb_finish(self.sketch_reader) + print("Sketch reader training finished") + # Train intensive reader + if module.lower() in ["all", "intensive"]: + print("Training intensive reader") + self.intensive_reader.train() + + print("Saving intensive reader") + self.intensive_reader.save_model() + + print("Saving intensive reader state") + self.intensive_reader.save_state() + + self.intensive_reader.free_memory() + wandb_finish(self.intensive_reader) + print("Intensive reader training finished") + print("Training finished") + + def inference(self, predict_examples: datasets.Dataset, return_submodule_outputs: bool = True) -> Tuple[Any]: + """ + Performs inference on the given predict examples dataset. + + Args: + predict_examples (datasets.Dataset): The dataset containing the predict examples. + return_submodule_outputs (bool, optional): Whether to return the outputs of the submodules. Defaults to False. + + Returns: + Tuple[Any]: A tuple containing the predictions, scores, and optionally the outputs (score_ext, nbest_preds, score_diff) of the submodules. + """ + # Add the example_id column if it doesn't exist + if "example_id" not in predict_examples.column_names: + predict_examples = predict_examples.map( + lambda _, i: {"example_id": str(i)}, + with_indices=True, + ) + + # Prepare the features for sketch reader and intensive reader + sketch_features = predict_examples.map( + self.sketch_prep_fn, + batched=True, + remove_columns=predict_examples.column_names, + ) + intensive_features = predict_examples.map( + self.intensive_prep_fn, + batched=True, + remove_columns=predict_examples.column_names, + ) + + # Perform inference on sketch reader + # self.sketch_reader.to(self.sketch_reader.args.device) + score_ext = self.sketch_reader.predict(sketch_features, predict_examples) + # self.sketch_reader.to("cpu") + + # Perform inference on intensive reader + # self.intensive_reader.to(self.intensive_reader.args.device) + nbest_preds, score_diff = self.intensive_reader.predict( + intensive_features, predict_examples, mode="retro_inference") + # self.intensive_reader.to("cpu") + + # Combine the outputs of the submodules + predictions, scores = self.rear_verifier(score_ext, score_diff, nbest_preds) + outputs = (predictions, scores) + + # Add the outputs of the submodules if required + if return_submodule_outputs: + outputs += (score_ext, nbest_preds, score_diff) + + return outputs + + def evaluate(self, test_dataset: datasets.Dataset) -> dict: + """ + Evaluates the model on the given test dataset. + + Args: + test_dataset (Dataset): The dataset containing the test examples and ground truth answers. + + Returns: + dict: A dictionary containing the evaluation metrics. + """ + # Perform inference on the test dataset + predictions, scores, score_ext, nbest_preds, score_diff = self.inference(test_dataset, return_submodule_outputs=True) + + # Extract ground truth answers + ground_truths = test_dataset[C.ANSWER_COLUMN_NAME] + + formatted_predictions = [] + for example, pred in zip(test_dataset, predictions): + formatted_predictions.append({ + 'id': example[C.ID_COLUMN_NAME], + 'prediction_text': pred, + 'no_answer_probability': 0.0 # Assuming no_answer_probability is 0 for simplicity + }) + + formatted_references = [] + for example in test_dataset: + formatted_references.append({ + 'id': example[C.ID_COLUMN_NAME], + 'answers': example[C.ANSWER_COLUMN_NAME], + }) + + # Return the evaluation metrics + return compute_squad_v2(EvalPrediction(predictions=formatted_predictions, label_ids=formatted_references)) + + @property + def null_score_diff_threshold(self): + return self.args.null_score_diff_threshold + + @null_score_diff_threshold.setter + def null_score_diff_threshold(self, val): + self.args.null_score_diff_threshold = val + + @property + def n_best_size(self): + return self.args.n_best_size + + @n_best_size.setter + def n_best_size(self, val): + self.args.n_best_size = val + + @property + def beta1(self): + return self.rear_verifier.beta1 + + @beta1.setter + def beta1(self, val): + self.rear_verifier.beta1 = val + + @property + def beta2(self): + return self.rear_verifier.beta2 + + @beta2.setter + def beta2(self, val): + self.rear_verifier.beta2 = val + + @property + def best_cof(self): + return self.rear_verifier.best_cof + + @best_cof.setter + def best_cof(self, val): + self.rear_verifier.best_cof = val + + @property + def rear_threshold(self): + return self.rear_verifier.thresh + + @rear_threshold.setter + def rear_threshold(self, val): + self.rear_verifier.thresh = val \ No newline at end of file diff --git a/train_squad_v2.py b/train_squad_v2.py new file mode 100644 index 0000000000000000000000000000000000000000..d58c6eb4ac39920ae3d42613bf98414737be33c7 --- /dev/null +++ b/train_squad_v2.py @@ -0,0 +1,160 @@ +import os +os.environ["TF_ENABLE_ONEDNN_OPTS"] = '0' + +from huggingface_hub import login + + +from typing import Union, Any, Dict +# from datasets.arrow_dataset import Batch + +import argparse +import datasets +from transformers.utils import logging, check_min_version +from transformers.utils.versions import require_version + +from retro_reader import RetroReader +from retro_reader.constants import EXAMPLE_FEATURES +import torch + +# Will error if the minimal version of Transformers is not installed. Remove at your own risks. +check_min_version("4.13.0.dev0") + +require_version("datasets>=1.8.0") + +logger = logging.get_logger(__name__) + + +def schema_integrate(example) -> Union[Dict, Any]: + title = example["title"] + question = example["question"] + context = example["context"] + guid = example["id"] + classtype = [""] * len(title) + dataset_name = source = ["squad_v2"] * len(title) + answers, is_impossible = [], [] + for answer_examples in example["answers"]: + if answer_examples["text"]: + answers.append(answer_examples) + is_impossible.append(False) + else: + answers.append({"text": [""], "answer_start": [-1]}) + is_impossible.append(True) + # The feature names must be sorted. + return { + "guid": guid, + "question": question, + "context": context, + "answers": answers, + "title": title, + "classtype": classtype, + "source": source, + "is_impossible": is_impossible, + "dataset": dataset_name, + } + + +# data augmentation for multiple answers +def data_aug_for_multiple_answers(examples) -> Union[Dict, Any]: + result = {key: [] for key in examples.keys()} + + def update(i, answers=None): + for key in result.keys(): + if key == "answers" and answers is not None: + result[key].append(answers) + else: + result[key].append(examples[key][i]) + + for i, (answers, unanswerable) in enumerate( + zip(examples["answers"], examples["is_impossible"]) + ): + answerable = not unanswerable + assert ( + len(answers["text"]) == len(answers["answer_start"]) or + answers["answer_start"][0] == -1 + ) + if answerable and len(answers["text"]) > 1: + for n_ans in range(len(answers["text"])): + ans = { + "text": [answers["text"][n_ans]], + "answer_start": [answers["answer_start"][n_ans]], + } + update(i, ans) + elif not answerable: + update(i, {"text": [], "answer_start": []}) + else: + update(i) + + return result + + +def main(args): + # Load SQuAD V2.0 dataset + print("Loading SQuAD v2.0 dataset ...") + squad_v2 = datasets.load_dataset("squad_v2") + # Integrate into the schema used in this library + # Note: The columns used for preprocessing are `question`, `context`, `answers` + # and `is_impossible`. The remaining columns are columns that exist to + # process other types of data. + + # Minize the dataset for debugging + if args.debug: + squad_v2["train"] = squad_v2["train"].select(range(5)) + squad_v2["validation"] = squad_v2["validation"].select(range(5)) + + print("Integrating into the schema used in this library ...") + squad_v2 = squad_v2.map( + schema_integrate, + batched=True, + remove_columns=squad_v2.column_names["train"], + features=EXAMPLE_FEATURES, + ) + # num_rows in train: 130,319, num_unanswerable in train: 43,498 + # num_rows in valid: 11,873, num_unanswerable in valid: 5,945 + num_unanswerable_train = sum(squad_v2["train"]["is_impossible"]) + num_unanswerable_valid = sum(squad_v2["validation"]["is_impossible"]) + logger.warning(f"Number of unanswerable sample for SQuAD v2.0 train dataset: {num_unanswerable_train}") + logger.warning(f"Number of unanswerable sample for SQuAD v2.0 validation dataset: {num_unanswerable_valid}") + # Train data augmentation for multiple answers + # no answer {"text": [], "answer_start": [-1]} -> {"text": [], "answer_start": []} + + print("Data augmentation for multiple answers ...") + squad_v2_train = squad_v2["train"].map( + data_aug_for_multiple_answers, + batched=True, + batch_size=args.batch_size, + num_proc=5, + ) + squad_v2 = datasets.DatasetDict({ + "train": squad_v2_train, # num_rows: 130,319 + "validation": squad_v2["validation"] # num_rows: 11,873 + }) + # Load Retro Reader + # features: parse arguments + # make train/eval dataset from examples + # load model from πŸ€— hub + # set sketch/intensive reader and rear verifier + print("Loading Retro Reader ...") + retro_reader = RetroReader.load( + train_examples=squad_v2["train"], + eval_examples=squad_v2["validation"], + config_file=args.configs, + device="cuda" if torch.cuda.is_available() else "cpu", + ) + if args.resume_checkpoint: + retro_reader = retro_reader.load_checkpoint(args.resume_checkpoint) + + # Train + print("Training ...") + retro_reader.train(module=args.module) + logger.warning("Train retrospective reader Done.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--configs", "-c", type=str, default="configs/train_distilbert.yaml", help="config file path") + parser.add_argument("--batch_size", "-b", type=int, default=1024, help="batch size") + parser.add_argument("--resume_checkpoint", "-r", type=str, default=None, help="resume checkpoint path") + parser.add_argument("--module", "-m", type=str, default="all", choices=["all", "sketch", "intensive"], help="module to train") + parser.add_argument("--debug", "-d", action="store_true", help="debug mode") + args = parser.parse_args() + main(args) \ No newline at end of file