├── .codespellrc ├── .coveragerc ├── .editorconfig ├── .github └── workflows │ ├── codespell.yml │ └── tests.yml ├── .gitignore ├── .gitmodules ├── CONTRIBUTING.md ├── ChangeLog ├── LICENSE ├── MANIFEST.in ├── README.md ├── data └── config-schema.json ├── docs ├── README.md ├── api-auth.md ├── gcalcli_1.png ├── gcalcli_2.png ├── gcalcli_3.png ├── gcalcli_4.png ├── gcalcli_5.png ├── gcalcli_5_sm.png └── man1 │ └── gcalcli.1 ├── gcalcli ├── __init__.py ├── _types.py ├── actions.py ├── argparsers.py ├── auth.py ├── cli.py ├── config.py ├── conflicts.py ├── deprecations.py ├── details.py ├── env.py ├── exceptions.py ├── gcal.py ├── ics.py ├── printer.py ├── utils.py └── validators.py ├── pyproject.toml ├── setup.py ├── stubs ├── google │ ├── auth │ │ ├── credentials.pyi │ │ ├── exceptions.pyi │ │ └── transport │ │ │ ├── __init__.pyi │ │ │ └── requests.pyi │ └── oauth2 │ │ └── credentials.pyi ├── google_auth_oauthlib │ └── flow.pyi ├── parsedatetime │ └── parsedatetime │ │ └── __init__.pyi └── vobject │ └── __init__.pyi ├── tests ├── README.md ├── cli │ ├── .ignore │ ├── __snapshot__ │ │ ├── test-02-test_prints_correct_help.snap │ │ ├── test-03-test_can_run_init.snap │ │ └── test-04-test_can_run_add.snap │ ├── run_tests.sh │ ├── test.bats │ └── test_helper │ │ └── .ignore ├── conftest.py ├── data │ ├── cal_list.json │ ├── cal_service_discovery.json │ ├── legacy_oauth_creds.json │ └── vv.txt ├── test_argparsers.py ├── test_auth.py ├── test_conflicts.py ├── test_gcalcli.py ├── test_input_validation.py ├── test_printer.py └── test_utils.py └── tox.ini /.codespellrc: -------------------------------------------------------------------------------- 1 | [codespell] 2 | skip = .git,*.pdf,*.svg 3 | # 4 | # ignore-words-list = 5 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | show_missing = True 3 | exclude_lines= 4 | pragma: no cover 5 | pragma: no py${PYTEST_PYMAJVER} cover 6 | omit = 7 | *__init__.py 8 | [paths] 9 | source = 10 | gcalcli/ 11 | .tox/py*/lib/python*/site-packages/gcalcli/ 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.{md,py,yml,yaml}] 4 | indent_style = space 5 | 6 | [*.py] 7 | indent_size = 4 8 | max_line_length = 80 9 | 10 | [*.{md,yml,yaml}] 11 | indent_size = 2 12 | 13 | [ChangeLog] 14 | indent_style = space 15 | indent_size = 2 16 | max_line_length = 80 17 | -------------------------------------------------------------------------------- /.github/workflows/codespell.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Codespell 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | pull_request: 8 | branches: [main] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | codespell: 15 | name: Check for spelling errors 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | - name: Codespell 22 | uses: codespell-project/actions-codespell@v2 23 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests (tox) 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | paths-ignore: 9 | - '*.md' 10 | - ChangeLog 11 | - LICENSE 12 | - docs/** 13 | 14 | permissions: 15 | contents: read 16 | 17 | env: 18 | PIP_DISABLE_PIP_VERSION_CHECK: 1 19 | 20 | jobs: 21 | test: 22 | name: test with ${{ matrix.py }} on ${{ matrix.os }} 23 | runs-on: ${{ matrix.os }} 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | py: 28 | - "3.12" 29 | - "3.11" 30 | - "3.10" 31 | os: 32 | - ubuntu-latest 33 | - macos-latest 34 | - windows-latest 35 | env: 36 | SKIP_ENVS: ${{ (matrix.py != '3.12' || matrix.os == 'windows-latest') && '--skip-env=cli' || '' }} 37 | steps: 38 | - uses: actions/checkout@v4 39 | with: 40 | fetch-depth: 0 41 | submodules: ${{ matrix.py == '3.12' && matrix.os != 'windows-latest' && 'true' || 'false' }} 42 | - name: Setup python for test ${{ matrix.py }} 43 | uses: actions/setup-python@v5 44 | with: 45 | python-version: ${{ matrix.py }} 46 | cache: 'pip' 47 | - name: Cache .mypy_cache 48 | uses: actions/cache@v4 49 | with: 50 | path: .mypy_cache 51 | key: ${{ matrix.os }}-${{ matrix.py }}-mypy 52 | - name: Install tox-gh 53 | run: python -m pip install tox-gh>=1.2 54 | - name: Setup test suite 55 | run: tox -vv --notest 56 | - name: Run test suite 57 | run: tox --skip-pkg-install ${{ env.SKIP_ENVS }} 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .pytest_cache/ 3 | build/ 4 | dist/ 5 | .env/ 6 | .venv/ 7 | venv/ 8 | htmlcov/ 9 | 10 | /gcalcli/_version.py 11 | gcalcli.egg-info 12 | 13 | .coverage 14 | .pytest_cache? 15 | .tox 16 | 17 | .python-version 18 | *.pyc 19 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "tests/cli/bats"] 2 | path = tests/cli/bats 3 | url = https://github.com/bats-core/bats-core.git 4 | [submodule "tests/cli/test_helper/bats-support"] 5 | path = tests/cli/test_helper/bats-support 6 | url = https://github.com/bats-core/bats-support.git 7 | [submodule "tests/cli/test_helper/bats-assert"] 8 | path = tests/cli/test_helper/bats-assert 9 | url = https://github.com/bats-core/bats-assert.git 10 | [submodule "tests/cli/test_helper/bats-snapshot"] 11 | path = tests/cli/test_helper/bats-snapshot 12 | url = https://github.com/markkong318/bats-snapshot.git 13 | [submodule "tests/cli/test_helper/bats-file"] 14 | path = tests/cli/test_helper/bats-file 15 | url = https://github.com/bats-core/bats-file.git 16 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to gcalcli 2 | 3 | Welcome! Glad you're interested in contributing to the project. ❤️ 4 | 5 | > If you want to support the project, there are plenty of other ways to show support besides writing code: 6 | > - Use the app and help catch issues 7 | > - Help troubleshoot other users' issues in the tracker 8 | > - Suggest improvements to instructions and documentation 9 | 10 | ## Community 11 | 12 | The issue tracker at https://github.com/insanum/gcalcli is the best place to reach out to maintainers or get help contributing. 13 | 14 | ## Code of Conduct 15 | 16 | Please strive to show respect and be welcoming of everyone. 17 | 18 | Please report unacceptable behavior to david AT mumind DOT me. 19 | 20 | ## Code review 21 | 22 | To contribute changes, please send a pull request to https://github.com/insanum/gcalcli. 23 | 24 | For nontrivial changes it's helpful to open a separate issue in the tracker to discuss the problem/idea, and to make sure tests are up-to-date and passing (see "Howto: Run tests" below). 25 | 26 | ### How reviews work 27 | 28 | Generally a maintainer will be notified of the PR and follow up to review it. You can explicitly request review from @dbarnett if you prefer. 29 | 30 | Feel free to comment to bump it for attention if nobody follows up after a week or so. 31 | 32 | ## Quality guidelines 33 | 34 | For now, you can just check that the tests and linters are happy and work with reviewers if they raise other quality concerns. 35 | 36 | Test coverage for new functionality is helpful, but not always required. Feel free to start reviews before you've finished writing tests and ask your reviewer for help implementing the right test coverage. 37 | 38 | ## Running tests 39 | 40 | Make sure you've installed [tox](https://tox.wiki/) and a supported python version, then run 41 | 42 | ```shell 43 | git submodule update --init 44 | tox 45 | ``` 46 | 47 | NOTE: You'll also want to install the "dev" extra in any development environment you're using 48 | that's not managed by tox (by changing install commands `gcalcli`->`gcalcli[dev]` or 49 | `.`->`.[dev]`). 50 | 51 | See [tests/README.md](tests/README.md) for more info on the tests. 52 | -------------------------------------------------------------------------------- /ChangeLog: -------------------------------------------------------------------------------- 1 | v4.5.2 2 | * Support oauth (and cache) files in $GCALCLI_CONFIG dir 3 | 4 | v4.5.1 5 | * Fix gcalcli failing to run on python 3.10 if config file is present 6 | * Fix `config edit` when missing config dir blowing up with FileNotFoundError 7 | * Fix bizarre SSL recursion errors by moving truststore init earlier 8 | * Fix redundant "Ignore and refresh" prompt from `init` 9 | * Adjust "when" value parsing to handle YYYY-MM-DD consistently 10 | 11 | v4.5.0 12 | * Drop support for python <3.10 13 | * Add `init` command to explicitly request auth setup/refresh 14 | * Improve auth issue handling and error messaging or invalid OAuth token 15 | issues (adrien-n) 16 | * Respect locally-installed certificates (ajkessel) 17 | * Re-add a `--noauth_local_server` to provide instructions for authenticating 18 | from a remote system using port forwarding 19 | * Add support for config.toml file and `gcalcli config edit` command 20 | * Behavior change: `--noincluderc` now skips gcalclirc files unconditionally, 21 | w/ or w/o --config-folder 22 | - POSSIBLE ACTION REQUIRED: Use `@path/to/gcalclirc` explicitly if it stops 23 | reading an rc file you needed 24 | * Migrate data files like ~/.gcalcli_oauth into standard data file paths 25 | (with fallback to migrate detected files into the new paths) 26 | * Add support for $GCALCLI_CONFIG env var and deprecate --config-folder 27 | * Add support for `gcalcli util config-schema|reset-cache|inspect-auth` 28 | commands 29 | * Fix parsing for calendar names containing '#' 30 | * `add` and similar commands determine date format to use based on system 31 | locale's in "When" inputs 32 | * `add` with `--default-reminders` won't prompt for additional reminders 33 | * Fix `import` crashing on empty ICS files 34 | * `import` can also handle events w/o a dtend, using duration if available 35 | * The `import` command now dumps events it couldn't import into a tmp rej.ics 36 | file in a tmp directory for convenient retries 37 | 38 | v4.4.0 39 | * Fix lots of bugs by switching from deprecated oauth2client to 40 | google_auth_oauthlib 41 | * Friendlier help output when `import` command is missing vobject extra 42 | * `import` command more gracefully handles existing events to avoid duplicates 43 | and unnecessary edits (tsheinen, cryhot) 44 | * Handle encoding/decoding errors more gracefully by replacing with 45 | placeholder chars instead of blowing up 46 | * Fix `--lineart` option failing with unicode errors 47 | * `quick` command now prompts for which calendar to use when ambiguous 48 | * Fix `--nodeclined` option failing on events with aliased email 49 | * Fix event list commands like `agenda` returning some events that don't 50 | actually match their search criteria due to pagination bug (kbulygin) 51 | * `add` command now supports `--end` as an alternative to `--duration` 52 | (michaelPotter) 53 | 54 | v4.3.0 55 | * Adds 'conference' to details display (michaelhoffman) 56 | 57 | v4.2.1 58 | * Remove python2 support 59 | * Allow flexible notion for durations (flicken) 60 | * new `conflicts` command (flicken) 61 | * Fixed issue when locale.nl_langinfo isn't available 62 | * Fixed IndexError when attendee cannot be found in _DeclinedEvent (navignaw) 63 | 64 | v4.2.0 65 | * Prompt user for calendar on `add' when it isn't specified 66 | * Add `end' time to details view 67 | * New `updates' command 68 | * Automatically use available console width 69 | 70 | v4.1.1 71 | * Fixed regression on now marking 72 | * Fixed version string management 73 | 74 | v4.1.0 75 | * Removed url shortening due to Google deprecation #440 76 | 77 | v4.0.4 78 | * Minor bugfixes: conky colors, issues with setup.py 79 | 80 | v4.0.0 81 | * Major code refactor: modularity, testing, PEP8 compliance 82 | * Bugfixes for issues reported during alpha phase 83 | 84 | v4.0.0a4 85 | * Multiday events support #277 86 | * Fix textwrap for widechar at cut index #308 87 | * Fix errors attempting to import events #325 88 | 89 | v4.0.0a3 90 | * No weekend option #264 91 | * Fixed bug with `add` and iterators #268 92 | * Deal with more encoding issues... #261 93 | * Get error from JSON object #260 94 | 95 | v4.0.0a2 96 | * Support for Python3 via six 97 | 98 | v4.0.0a1 99 | * Move from gflags to argparse 100 | This is a major, non-backwards compatible change (hence the roll up to v4) 101 | 102 | v3.4.0 103 | * Support for adding attendees (jcmuller) 104 | * Fix crash when organizer/attendee don't have emails (tschwinge) 105 | * TSV export support `--no-started` (matthewdavis) 106 | * Support for displaying attachment links (shi2wei3) 107 | * Allow ignoring declined events (dmathieu) 108 | * Warning if cache appears to be stale (nealmcb) 109 | * search now supports start and end times like agenda (watersm) 110 | * current event is proper colored in agenda (karlicoss) 111 | 112 | v3.3.2 113 | * More ascii vs. unicode issues (*le sigh*) 114 | * Use correct dateutil package (python-dateutil) 115 | 116 | v3.3.1 117 | * TSV support for search 118 | * `--detail email` to display event creator's address 119 | * Pin oauth2client version to prevent issues with gflags 120 | * Updated README with options to use custom client_id/client_secret 121 | 122 | v3.3 123 | * Support for adding All Day events (238d527 / SBECK-github) 124 | * Fix date display issues (e9a4a24 / njoyard) 125 | * Attempt fix for per-account quota errors (6416c7d) 126 | 127 | v3.2 128 | * Add enhanced reminder functionality (393993b / cc2c4cc) 129 | * Unicode cleanup (debe5bf) 130 | * Add --defaultCalendar option (cf9cdf5) 131 | * Respect --detail options for TSV output (013d5dc) 132 | * Speed up tsv output by only shortening links on demand (--detail_url short) 133 | * PEP8 cleanup FTW! (adea810) 134 | * Fix validator for --details not liking 'description' (a4ad28c) 135 | * Fix "now marker" showing on wrong days at times (7479e21) 136 | * Added support for displaying attendees (56ade18 / metcalfc) 137 | 138 | v3.1 139 | * Enhancements 140 | - Ported to use the Google API Client library and Google Calendar v3 spec 141 | now required: https://developers.google.com/api-client-library/python 142 | the Google GData python module is now deprecated and no longer used 143 | - OAuth2 authentication, all traces of username and password removed 144 | - support for URL shortening via goo.gl 145 | - the --detail-url=[long,short] is now accepted by most commands 146 | - new 'delete' command used to interactively delete event(s) 147 | new --iama-expert option is automatically delete event(s) 148 | - new 'edit' command used to interactively edit event(s) 149 | - new "now marker" in the 'calw' and 'calm' output that shows a line 150 | representing the current time (i.e. like that seen in the Google 151 | Calendar week/day views), new --now-marker-color changes line color 152 | - new --detail-calendar option to print the calendar an event belongs 153 | - terminal graphics now used for lines, use --nl option to turn them off 154 | - the --cals option to limit calendars by access role has been removed 155 | - the 'search' command now supports proper Google-like search terms 156 | - the 'import' command now accepts a '-d' option that is used for printing 157 | out the events found in an ics/vcal file and not importing them 158 | * Fixes 159 | - the 'quick', 'add', and 'import' commands now require a single --cal option 160 | - lots of code reduction and simplification based on new Google API Client 161 | - tsv output 162 | - nostarted was semi-broken and should now be all better 163 | 164 | v2.4.2 165 | * Fix unicode encoding issues 166 | * Stop trying to display multiple events on single line 167 | 168 | v2.4.1 169 | * Fixed tsv output 170 | 171 | v2.4 172 | * Added support for conky color sequences 173 | * Support --reminder when using ics/vcal import 174 | * Don't print empty descriptions 175 | * Add support for fuzzy dates (today, tomorrow, next week) using parsedatetime 176 | * Empty descriptions no longer printed 177 | * Fixed print locations and reminders for agenda 178 | * Allow outputting event URL as short URL using goo.gl 179 | * Really minor change to display end dates in the --tsv view mode. 180 | 181 | v2.3 182 | * Enhancements 183 | - new 'add' command for adding new events either interactively or 184 | automatically with the new --title --where --when --duration --descr 185 | options 186 | - new --reminder option to specify a reminder time (in minutes) for the 187 | 'quick' and 'add' commands 188 | - event details in 'agenda' output can now be selectively seen using 189 | the new --detail-all --detail-location --detail-length 190 | --detail-reminders --detail-descr --detail-descr-width options 191 | - new --locale option added to override the default locale 192 | - new --tsv option used for tab separated values 'agenda' output 193 | - organizer and attendees are now imported from ics/vcal files 194 | - doc updates including how to integrate with Thunderbird and Mutt 195 | https://github.com/insanum/gcalcli#readme 196 | * Fixes 197 | - the --cal option now works properly when adding events 198 | - now ONLY https is used when communicating with Google's servers 199 | - lots of other fixes: https://github.com/insanum/gcalcli/issues 200 | 201 | v2.2 202 | * never tagged and released (development for v2.3) 203 | 204 | v2.1 205 | * Enhancements 206 | - new import command for importing ics/vcal files to a calendar 207 | - add events to any calendar instead of just the default 208 | - ability to override the color for a specific calendar 209 | - added ability to specify calendars and colors in the config file 210 | - new --https option to force using SSL 211 | - new --mon option to display calw and calm weeks starting with Monday 212 | - new --24hr option for displaying timestamps in 24 hour format 213 | - all day events are no longer shown with a timestamp 214 | - interactively prompt for a password if none is found 215 | - calendar data gathering is now multi-threaded for performance 216 | * Fixes 217 | - all unicode problems should now be fixed 218 | - calw and calm displays can now handle wide east asian unicode characters 219 | - use only ANSI C strftime formats for cross platform compatibility 220 | - --ignore-events now works for the agenda and search commands 221 | - all day events on Sunday no longer show again on the next week 222 | - fixed calw and calm layout issues with events that have no titles 223 | - dump events that are beyond year 2038 (really?) 224 | 225 | v1.4 226 | - colors are now supported in the 'calw' and 'calm' displays 227 | - new --border-color switch 228 | 229 | v1.3 230 | - new '--cal' switch used to specify a single calendar or multiple using 231 | a regex 232 | - config file support (~/.gcalclirc or override on command line) 233 | - new 'calm' and 'calw' command that displays a nice graphical 234 | representation of your calendar 235 | - new '--ignore-started' switch 236 | - fixed time display (am/pm) for Mac OSX 237 | - the 'remind' command now works against all specified calendars 238 | - support for 'editor' calendars 239 | 240 | v1.2 241 | - support unicode input and output 242 | 243 | v1.1 244 | - initial release 245 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Eric Davis, Brian Hartvigsen, Joshua Crowgey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include ChangeLog 2 | include README.md 3 | exclude .git* 4 | 5 | graft data 6 | graft docs 7 | prune .github 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | docs/README.md -------------------------------------------------------------------------------- /data/config-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "title": "gcalcli config", 4 | "description": "User configuration for gcalcli command-line tool.\n\nSee https://pypi.org/project/gcalcli/.", 5 | "type": "object", 6 | "$defs": { 7 | "AuthSection": { 8 | "title": "Settings for authentication", 9 | "description": "Configuration for settings like client-id used in auth flow.\n\nNote that client-secret can't be configured here and should be passed on\ncommand-line instead for security reasons.", 10 | "type": "object", 11 | "properties": { 12 | "client-id": { 13 | "title": "Client ID for Google auth token", 14 | "description": "Google client ID for your auth client project.\n\nDetails: https://github.com/insanum/gcalcli/blob/HEAD/docs/api-auth.md", 15 | "anyOf": [ 16 | { 17 | "type": "string" 18 | }, 19 | { 20 | "type": "null" 21 | } 22 | ], 23 | "default": null 24 | } 25 | } 26 | }, 27 | "CalendarsSection": { 28 | "title": "Settings about the set of calendars gcalcli operates on", 29 | "type": "object", 30 | "properties": { 31 | "default-calendars": { 32 | "title": "Calendars to use as default for certain commands when no explicit target calendar is otherwise specified", 33 | "type": "array", 34 | "items": { 35 | "type": "string" 36 | } 37 | }, 38 | "ignore-calendars": { 39 | "title": "Calendars to ignore by default (unless explicitly included using --calendar) when running commands against all calendars", 40 | "type": "array", 41 | "items": { 42 | "type": "string" 43 | } 44 | } 45 | } 46 | }, 47 | "OutputSection": { 48 | "title": "Settings about gcalcli output (formatting, colors, etc)", 49 | "type": "object", 50 | "properties": { 51 | "week-start": { 52 | "title": "Weekday to treat as start of week", 53 | "$ref": "#/$defs/WeekStart", 54 | "default": "sunday" 55 | } 56 | } 57 | }, 58 | "WeekStart": { 59 | "title": "WeekStart", 60 | "type": "string", 61 | "enum": [ 62 | "sunday", 63 | "monday" 64 | ] 65 | } 66 | }, 67 | "properties": { 68 | "auth": { 69 | "$ref": "#/$defs/AuthSection" 70 | }, 71 | "calendars": { 72 | "$ref": "#/$defs/CalendarsSection" 73 | }, 74 | "output": { 75 | "$ref": "#/$defs/OutputSection" 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # gcalcli 2 | 3 | Google Calendar Command Line Interface 4 | 5 | [![Build Status](https://github.com/insanum/gcalcli/actions/workflows/tests.yml/badge.svg)](https://github.com/insanum/gcalcli/actions/workflows/tests.yml) 6 | 7 | gcalcli is a Python application that allows you to access your Google 8 | Calendar(s) from a command line. It's easy to get your agenda, search for 9 | events, add new events, delete events, edit events, see recently updated 10 | events, and even import those annoying ICS/vCal invites from Microsoft 11 | Exchange and/or other sources. Additionally, gcalcli can be used as a reminder 12 | service and execute any application you want when an event is coming up. 13 | 14 | gcalcli uses the [Google Calendar API version 15 | 3](https://developers.google.com/calendar/api/v3/reference/). 16 | 17 | ## Features 18 | 19 | * OAuth2 authentication with your Google account 20 | * list your calendars 21 | * show an agenda using a specified start/end date and time 22 | * show updates since a specified datetime for events between a start/end date and time 23 | * find conflicts between events matching search term 24 | * ascii text graphical calendar display with variable width 25 | * search for past and/or future events 26 | * "quick add" new events to a specified calendar 27 | * "add" a new event to a specified calendar (interactively or automatically) 28 | * "delete" event(s) from a calendar(s) (interactively or automatically) 29 | * "edit" event(s) interactively 30 | * import events from ICS/vCal files to a specified calendar 31 | * easy integration with your favorite mail client (attachment handler) 32 | * run as a cron job and execute a command for reminders 33 | * work against specific calendars (by calendar name w/ regex) 34 | * flag file support for specifying option defaults 35 | * colored output and unicode character support 36 | * custom shell completion for bash, zsh, fish, etc 37 | * super fun hacking with shell scripts, cron, screen, tmux, conky, etc 38 | 39 | [![Screenshot of agenda and calendar view](https://raw.githubusercontent.com/insanum/gcalcli/HEAD/docs/gcalcli_5_sm.png)](https://raw.githubusercontent.com/insanum/gcalcli/HEAD/docs/gcalcli_5.png) 40 | 41 | 42 | ## Requirements 43 | 44 | Installing and using gcalcli requires python 3, the dependencies listed in 45 | pyproject.toml, and a love for the command line! 46 | 47 | ## Installation 48 | 49 | Check your OS distribution for packages. 50 | 51 | If your OS doesn't have the latest released version you can install using pip 52 | (or pipx). 53 | 54 | ### Install on Linux 55 | 56 | Several Linux distros have packages available. A few popular ones... 57 | 58 | * Debian/Ubuntu: `sudo apt install gcalcli` 59 | * Void Linux: `xbps-install gcalcli` 60 | 61 | ### Install using [Nix](https://nixos.org/nix/) 62 | 63 | ```shell 64 | nix-env -i gcalcli 65 | ``` 66 | 67 | ### Install using [Homebrew](https://brew.sh/) (MacOS) 68 | 69 | ```shell 70 | brew install gcalcli 71 | ``` 72 | 73 | ### Install from PyPI 74 | 75 | ```shell 76 | pip install gcalcli[vobject] 77 | # OR: pipx install gcalcli[vobject] 78 | ``` 79 | 80 | If you don't need the `import` command you can install without extras: 81 | 82 | ```shell 83 | pip install gcalcli 84 | ``` 85 | 86 | ### Install from source 87 | 88 | ```sh 89 | git clone https://github.com/insanum/gcalcli.git 90 | cd gcalcli 91 | pip install .[vobject] 92 | ``` 93 | 94 | ## Usage 95 | 96 | `gcalcli` provides a series of subcommands with the following functionality: 97 | 98 | init initialize authentication, etc 99 | list list available calendars 100 | edit edit calendar events 101 | agenda get an agenda for a time period 102 | updates get updates since a datetime for a time period 103 | calw get a week-based agenda in calendar format 104 | calm get a month agenda in calendar format 105 | quick quick-add an event to a calendar 106 | add add a detailed event to the calendar 107 | import import an ics/vcal file to a calendar 108 | remind execute command if event occurs within time 109 | 110 | See the manual (`man (1) gcalcli`), or run with `--help`/`-h` for detailed usage. 111 | 112 | ### Initial setup 113 | 114 | OAuth2 is used for authenticating with your Google account. The resulting token 115 | is placed in an `oauth` file in your platform's data directory (for example 116 | ~/.local/share/gcalcli/oauth on Linux). When you first start gcalcli the 117 | authentication process will proceed. Simply follow the instructions. 118 | 119 | **You currently have to use your own Calendar API token.** Our Calendar API token is restricted to few users only and waits for Google's approval to be unlocked. 120 | 121 | Set up your Google "project" and auth token as explained in 122 | [docs/auth-api.md](https://github.com/insanum/gcalcli/blob/HEAD/docs/api-auth.md), 123 | then run gcalcli passing a `--client-id` to finish setup: 124 | 125 | ```shell 126 | gcalcli --client-id=xxxxxxxxxxxxxxx.apps.googleusercontent.com init 127 | ``` 128 | 129 | Enter the client secret when prompted and follow its directions to complete the permission flow. 130 | 131 | ### Shell completion 132 | 133 | gcalcli provides command completion you can configure in bash, zsh, fish, etc using the [https://kislyuk.github.io/argcomplete/] library. 134 | 135 | To enable it, follow argcomplete's setup instructions to ensure your shell can find the completion hooks. 136 | 137 | ```shell 138 | gcalcli 139 | add 140 | agenda 141 | agendaupdate 142 | ... 143 | ``` 144 | 145 | NOTE: Setup for fish and other shells is currently explained [under "contrib"](https://github.com/kislyuk/argcomplete/tree/develop/contrib) instead of their main docs, and their centralized "global activation" mechanism doesn't seem to be supported yet for those shells. 146 | 147 | ### HTTP Proxy Support 148 | 149 | gcalcli will automatically work with an HTTP Proxy simply by setting up some 150 | environment variables used by the gdata Python module: 151 | 152 | ``` 153 | http_proxy 154 | https_proxy 155 | proxy-username or proxy_username 156 | proxy-password or proxy_password 157 | ``` 158 | 159 | Note that these environment variables must be lowercase. 160 | 161 | ### Configuration 162 | 163 | gcalcli supports some configuration options in a config.toml file under your 164 | platform's standard config directory path. Edit it with `gcalcli config edit`. 165 | 166 | Example: 167 | 168 | ```toml 169 | #:schema https://raw.githubusercontent.com/insanum/gcalcli/HEAD/data/config-schema.json 170 | [calendars] 171 | default-calendars = ["Personal", "Work"] 172 | ignore-calendars = ["Boring stuff", "Holidays"] 173 | 174 | [output] 175 | week-start = "monday" 176 | ``` 177 | 178 | You can also use the $GCALCLI_CONFIG environment variable to customize which 179 | config file/directory to use, which is useful if you need to dynamically switch 180 | between different sets of configuration. For example: 181 | 182 | ```shell 183 | GCALCLI_CONFIG=~/.config/gcalcli/config.tuesdays.toml gcalcli add 184 | ``` 185 | 186 | #### Using cli args from a file (and gcalclirc flag file) 187 | 188 | You can save commonly-used options in a file and load them into cli options 189 | using an `@` prefix. For example: 190 | 191 | ```shell 192 | gcalcli @~/.gcalcli_global_flags add \ 193 | @~/.gcalcli_add_flags 194 | ``` 195 | 196 | will insert flags listed in a ~/.gcalcli_global_flags file (one per line), then 197 | load more flags specific to the add command from ~/.gcalcli_add_flags. 198 | 199 | The flag files should have a set of cli args one per line (with no blank lines 200 | in between) like: 201 | 202 | ```shell 203 | --nocache 204 | --nocolor 205 | --default-calendar=CALENDAR_NAME 206 | --client-secret=API_KEY 207 | ``` 208 | 209 | Note that long options require an equal sign if specifying a parameter. With 210 | short options the equal sign is optional. 211 | 212 | Currently any file named "gcalclirc" in your config directory (or a ~/.gcalclirc 213 | file) will be automatically loaded unconditionally like that as global options, 214 | but that mechanism may change in the future because it's more brittle than 215 | config.toml. 216 | 217 | #### Importing VCS/VCAL/ICS Files from Exchange (or other) 218 | 219 | Importing events from files is easy with gcalcli. The 'import' command accepts 220 | a filename on the command line or can read from standard input. Here is a script 221 | that can be used as an attachment handler for Thunderbird or in a mailcap entry 222 | with Mutt (or in Mutt you could just use the attachment viewer and pipe command): 223 | 224 | ```bash 225 | #!/bin/bash 226 | 227 | TERMINAL=evilvte 228 | CONFIG=~/.gcalclirc 229 | 230 | $TERMINAL -e bash -c "echo 'Importing invite...' ; \ 231 | gcalcli --detail-url=short \ 232 | --calendar='Eric Davis' \ 233 | import -v \"$1\" ; \ 234 | read -p 'press enter to exit: '" 235 | ``` 236 | 237 | Note that with Thunderbird you'll have to have the 'Show All Body Parts' 238 | extension installed for seeing the calendar attachments when not using 239 | 'Lightning'. See this 240 | [bug report](https://bugzilla.mozilla.org/show_bug.cgi?id=505024) 241 | for more details. 242 | 243 | ### Event Popup Reminders 244 | 245 | The 'remind' command for gcalcli is used to execute any command as an event 246 | notification. This can be a notify-send or an xmessage-like popup or whatever 247 | else you can think of. gcalcli does not contain a daemon so you'll have to use 248 | some other tool to ensure gcalcli is run in a timely manner for notifications. 249 | Two options are using cron or a loop inside a shell script. 250 | 251 | Cron: 252 | ```sh 253 | % crontab -l 254 | */10 * * * * /usr/bin/gcalcli remind 255 | ``` 256 | 257 | Shell script like your .xinitrc so notifications only occur when you're logged 258 | in via X: 259 | ```bash 260 | #!/bin/bash 261 | 262 | [[ -x /usr/bin/dunst ]] && /usr/bin/dunst -config ~/.dunstrc & 263 | 264 | if [ -x /usr/bin/gcalcli ]; then 265 | while true; do 266 | /usr/bin/gcalcli --calendar="davis" remind 267 | sleep 300 268 | done & 269 | fi 270 | 271 | exec herbstluftwm # :-) 272 | ``` 273 | 274 | By default gcalcli executes the notify-send command for notifications. Most 275 | common Linux desktop environments already contain a DBUS notification daemon 276 | that supports libnotify so it should automagically just work. If you're like 277 | me and use nothing that is common I highly recommend the 278 | [dunst](https://github.com/knopwob/dunst) dmenu'ish notification daemon. 279 | 280 | Note that each time you run this you will get a reminder if you're still inside 281 | the event duration. Also note that due to time slip between machines, gcalcli 282 | will give you a ~5 minute margin of error. Plan your cron jobs accordingly. 283 | 284 | ### Agenda On Your Root Desktop 285 | 286 | Put your agenda on your desktop using 287 | [Conky](https://github.com/brndnmtthws/conky). The '--conky' option causes 288 | gcalcli to output Conky color sequences. Note that you need to use the Conky 289 | 'execpi' command for the gcalcli output to be parsed for color sequences. Add 290 | the following to your .conkyrc: 291 | 292 | ```conkyrc 293 | ${execpi 300 gcalcli --conky agenda} 294 | ``` 295 | 296 | To also get a graphical calendar that shows the next three weeks add: 297 | 298 | ```conkyrc 299 | ${execpi 300 gcalcli --conky calw 3} 300 | ``` 301 | 302 | You may need to increase the `text_buffer_size` in your conkyrc file. Users 303 | have reported that the default of 256 bytes is too small for busy calendars. 304 | 305 | Additionally you need to set `--lineart=unicode` to output unicode-characters 306 | for box drawing. To avoid misaligned borders use a monospace font like 'DejaVu 307 | Sans Mono'. On Python2 it might be necessary to set the environment variable 308 | `PYTHONIOENCODING=utf8` if you are using characters beyond ascii. For 309 | example: 310 | ``` 311 | ${font DejaVu Sans Mono:size=9}${execpi 300 export PYTHONIOENCODING=utf8 && gcalcli --conky --lineart=unicode calw 3} 312 | ``` 313 | 314 | ### Agenda Integration With tmux 315 | 316 | Put your next event in the left of your 'tmux' status line. Add the following 317 | to your tmux.conf file: 318 | 319 | ```tmux 320 | set-option -g status-interval 60 321 | set-option -g status-left "#[fg=blue,bright]#(gcalcli agenda | head -2 | tail -1)#[default]" 322 | ``` 323 | 324 | ### Agenda Integration With screen 325 | 326 | Put your next event in your 'screen' hardstatus line. First add a cron job 327 | that will dump you agenda to a text file: 328 | 329 | ```shell 330 | % crontab -e 331 | ``` 332 | 333 | Then add the following line: 334 | 335 | ```shell 336 | */5 * * * * gcalcli --nocolor --nostarted agenda "`date`" > /tmp/gcalcli_agenda.txt 337 | ``` 338 | 339 | Next create a simple shell script that will extract the first agenda line. 340 | Let's call this script 'screen_agenda': 341 | 342 | ```sh 343 | #!/bin/bash 344 | head -2 /tmp/gcalcli_agenda.txt | tail -1 345 | ``` 346 | 347 | Next configure screen's hardstatus line to gather data from a backtick command. 348 | Of course your hardstatus line is most likely very different than this: 349 | 350 | ```screenrc 351 | backtick 1 60 60 screen_agenda 352 | hardstatus "[ %1` ]" 353 | ``` 354 | 355 | ## More screenshots 356 | 357 | ![gcalcli 1](https://raw.githubusercontent.com/insanum/gcalcli/HEAD/docs/gcalcli_1.png) 358 | 359 | ![gcalcli 2](https://raw.githubusercontent.com/insanum/gcalcli/HEAD/docs/gcalcli_2.png) 360 | 361 | ![gcalcli 3](https://raw.githubusercontent.com/insanum/gcalcli/HEAD/docs/gcalcli_3.png) 362 | 363 | Reminder popup: 364 | 365 | ![Reminder popup](https://raw.githubusercontent.com/insanum/gcalcli/HEAD/docs/gcalcli_4.png) 366 | -------------------------------------------------------------------------------- /docs/api-auth.md: -------------------------------------------------------------------------------- 1 | # Authenticating for the Google APIs 2 | 3 | The gcalcli needs to be granted permission to integrate with Google APIs for your account before it 4 | can work properly. 5 | 6 | These initial setup steps can look a little intimidating, but they're completely safe and similar 7 | to the setup currently needed for most non-commercial projects that integrate with Google APIs 8 | (for example, [gmailctl] or 9 | [Home Assistant](https://www.home-assistant.io/integrations/google_assistant/)). 10 | 11 | [gmailctl]: https://github.com/mbrt/gmailctl 12 | 13 | ## Part 1: Setting up your Google "project" 14 | 15 | To generate an OAuth token, you first need a placeholder "project" in Google Cloud Console. Create 16 | a new project if you don't already have a generic one you can reuse. 17 | 18 | 1. [Create a New Project](https://console.developers.google.com/projectcreate) within the Google 19 | developer console 20 | 1. (You can skip to the next step if you already have a placeholder project you can use) 21 | 2. Pick any project name. Example: "Placeholder Project 12345" 22 | 3. Click the "Create" button. 23 | 2. [Enable the Google Calendar API](https://console.developers.google.com/apis/api/calendar-json.googleapis.com/) 24 | 1. Click the "Enable" button. 25 | 26 | ## Part 2: Creating an auth client and token 27 | 28 | Once you have the project with Calendar API enabled, you need a way for gcalcli to request 29 | permission to use it on a user's account. 30 | 31 | 1. [Create OAuth2 consent screen](https://console.developers.google.com/apis/credentials/consent/edit;newAppInternalUser=false) for a "UI/Desktop Application". 32 | 1. Fill out required App information section 33 | 1. Specify App name. Example: "gcalcli" 34 | 2. Specify User support email. Example: your@gmail.com 35 | 2. Fill out required Developer contact information 36 | 1. Specify Email addresses. Example: your@gmail.com 37 | 3. Click the "Save and continue" button. 38 | 4. Scopes: click the "Save and continue" button. 39 | 5. Test users 40 | 1. Add your@gmail.com 41 | 2. Click the "Save and continue" button. 42 | 2. [Create OAuth Client ID](https://console.developers.google.com/apis/credentials/oauthclient) 43 | 1. Specify Application type: Desktop app. 44 | 2. Click the "Create" button. 45 | 3. Grab your newly created Client ID (in the form "xxxxxxxxxxxxxxx.apps.googleusercontent.com") and Client Secret from the Credentials page. 46 | 47 | You'll give these values to gcalcli to use in its setup flow. 48 | 49 | ## Last part: gcalcli auth setup 50 | 51 | Use those client ID and secret values to finish granting gcalcli permission to access your 52 | calendar. 53 | 54 | Run gcalcli passing a `--client-id`: 55 | 56 | ```shell 57 | $ gcalcli --client-id=xxxxxxxxxxxxxxx.apps.googleusercontent.com list 58 | ``` 59 | 60 | If it isn't already set up with permission, it will prompt you through remaining steps to enter 61 | your client secret, launch a browser to log into Google and grant permission. 62 | 63 | If it completes successfully you'll see a list of your Google calendars in the cli. 64 | 65 | NOTE: You'll generally see a big scary security warning page during this process and need to click 66 | "Advanced > Go to gcalcli (unsafe)" during this process because it's running a local setup flow for 67 | a non-Production client project. 68 | 69 | ## FAQ 70 | 71 | ### Can you make this easier? 72 | 73 | Probably not, unless Google makes changes to allow simpler authorization for non-commercial 74 | projects. 75 | 76 | See https://github.com/insanum/gcalcli/issues/692 for details. 77 | 78 | ### Is this really secure? 79 | 80 | Yes, totally secure. The scary security warnings aren't relevant to this kind of usage. 81 | 82 | The warnings in the browser are to protect against phishing by impersonating another project's 83 | "client" to trick users into trusting it. 84 | 85 | It's best to avoid sharing the client secret. Those credentials could allow someone to impersonate 86 | gcalcli and use the permission you've granted it to access your private calendars (though other 87 | Google security protections would probably make that difficult to exploit in practice). 88 | -------------------------------------------------------------------------------- /docs/gcalcli_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insanum/gcalcli/91db693ab6cef28a62158bbf07091d10a72e63f4/docs/gcalcli_1.png -------------------------------------------------------------------------------- /docs/gcalcli_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insanum/gcalcli/91db693ab6cef28a62158bbf07091d10a72e63f4/docs/gcalcli_2.png -------------------------------------------------------------------------------- /docs/gcalcli_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insanum/gcalcli/91db693ab6cef28a62158bbf07091d10a72e63f4/docs/gcalcli_3.png -------------------------------------------------------------------------------- /docs/gcalcli_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insanum/gcalcli/91db693ab6cef28a62158bbf07091d10a72e63f4/docs/gcalcli_4.png -------------------------------------------------------------------------------- /docs/gcalcli_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insanum/gcalcli/91db693ab6cef28a62158bbf07091d10a72e63f4/docs/gcalcli_5.png -------------------------------------------------------------------------------- /docs/gcalcli_5_sm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insanum/gcalcli/91db693ab6cef28a62158bbf07091d10a72e63f4/docs/gcalcli_5_sm.png -------------------------------------------------------------------------------- /docs/man1/gcalcli.1: -------------------------------------------------------------------------------- 1 | .\" Manpage for gcalcli. 2 | .TH man 1 "28 September 2024" "4.5.0" "gcalcli Manual" 3 | .SH NAME 4 | gcalcli \- interact with a Google Calendar 5 | .SH SYNOPSIS 6 | gcalcli [options] command [command args or options] 7 | .SH DESCRIPTION 8 | gcalcli is a Python application that allows you to access your Google 9 | Calendar(s) from a command line. It's easy to get your agenda, search for 10 | events, add new events, delete events, edit events, and even import those 11 | annoying ICS/vCal invites from Microsoft Exchange and/or other sources. 12 | Additionally, gcalcli can be used as a reminder service and execute any 13 | application you want when an event is coming up. 14 | .SH USAGE 15 | 16 | usage: gcalcli [-h] [--version] [--client-id CLIENT_ID] 17 | [--client-secret CLIENT_SECRET] [--noauth_local_server] 18 | [--config-folder CONFIG_FOLDER] [--noincluderc] 19 | [--calendar GLOBAL_CALENDARS] 20 | [--default-calendar DEFAULT_CALENDARS] [--locale LOCALE] 21 | [--refresh] [--nocache] [--conky] [--nocolor] 22 | [--lineart {fancy,unicode,ascii}] 23 | {init,list,search,edit,delete,agenda,agendaupdate,updates,conflicts,calw,calm,quick,add,import,remind,config,util} 24 | ... 25 | 26 | Google Calendar Command Line Interface 27 | 28 | configuration: 29 | gcalcli supports a few other configuration mechanisms in addition to 30 | the command-line arguments listed below. 31 | 32 | $GCALCLI_CONFIG=~/.config/gcalcli 33 | Path to user config directory or file. 34 | Note: this path is also used to determine fallback paths to check 35 | for cache/oauth files to be migrated into their proper data dir 36 | paths. 37 | 38 | ~/.config/gcalcli/config.toml 39 | A toml config file where some general-purpose settings can be 40 | configured. 41 | Schema: 42 | https://raw.githubusercontent.com/insanum/gcalcli/HEAD/data/config-schema.json 43 | 44 | gcalclirc @ ~/.config/gcalcli/gcalclirc 45 | A flag file listing additional command-line args to always pass, 46 | one per line. 47 | Note: Use this sparingly and prefer other configuration mechanisms 48 | where available. This flag file mechanism can be brittle 49 | (example: https://github.com/insanum/gcalcli/issues/513). 50 | 51 | positional arguments: 52 | {init,list,search,edit,delete,agenda,agendaupdate,updates,conflicts,calw,calm,quick,add,import,remind,config,util} 53 | Invoking a subcommand with --help prints subcommand 54 | usage. 55 | init initialize authentication, etc 56 | list list available calendars 57 | search search for events within an optional time period 58 | edit edit calendar events 59 | delete delete events from the calendar 60 | agenda get an agenda for a time period 61 | agendaupdate update calendar from agenda TSV file 62 | updates get updates since a datetime for a time period 63 | (defaults to through end of current month) 64 | conflicts find event conflicts 65 | calw get a week-based agenda in calendar format 66 | calm get a month agenda in calendar format 67 | quick quick-add an event to a calendar 68 | add add a detailed event to the calendar 69 | import import an ics/vcal file to a calendar 70 | remind execute command if event occurs within time 71 | config utility commands to work with configuration 72 | util low-level utility commands for introspection, dumping 73 | schemas, etc 74 | 75 | options: 76 | -h, --help show this help message and exit 77 | --version show program's version number and exit 78 | --client-id CLIENT_ID 79 | API client_id (default: None) 80 | --client-secret CLIENT_SECRET 81 | API client_secret (default: None) 82 | --noauth_local_server 83 | Provide instructions for authenticating from a remote 84 | system using port forwarding. Note: Previously this 85 | option invoked an "Out-Of-Band" variant of the auth 86 | flow, but that deprecated mechanism is no longer 87 | supported. (default: True) 88 | --config-folder CONFIG_FOLDER 89 | Optional directory used to load config files. 90 | Deprecated: prefer $GCALCLI_CONFIG. (default: 91 | ~/.config/gcalcli) 92 | --noincluderc Whether to include ~/.gcalclirc. (default: True) 93 | --calendar GLOBAL_CALENDARS 94 | Which calendars to use, in the format "CalendarName" 95 | or "CalendarName#color". Supported here globally for 96 | compatibility purposes, but prefer passing to 97 | individual commands after the command name since this 98 | global version is brittle. (default: []) 99 | --default-calendar DEFAULT_CALENDARS 100 | Optional default calendar to use if no --calendar 101 | options are given (default: []) 102 | --locale LOCALE System locale (default: ) 103 | --refresh Delete and refresh cached data (default: False) 104 | --nocache Execute command without using cache (default: True) 105 | --conky Use Conky color codes (default: False) 106 | --nocolor Enable/Disable all color output (default: True) 107 | --lineart {fancy,unicode,ascii} 108 | Choose line art style for calendars: "fancy": for 109 | VTcodes, "unicode" for Unicode box drawing characters, 110 | "ascii" for old-school plusses, hyphens and pipes. 111 | (default: fancy) 112 | 113 | .SH DIAGNOSTICS 114 | .B "Encoding issues, wrongly printed characters, UnicodeEncodeError when using 115 | conky, in pipes or scripts" 116 | .RS 117 | - Set an environment variable PYTHONIOENCODING=utf8 118 | - Use a Unicode capable font like DejaVu Sans Mono 119 | - Set option --lineart=unicode or --lineart=ascii 120 | .RE 121 | .B "No colors or garbage characters when using inside conky" 122 | .RS 123 | - Set option --conky 124 | .RE 125 | .SH SEE ALSO 126 | bash(1) 127 | 128 | .SH BUG REPORTS 129 | Report issues at https://github.com/insanum/gcalcli/issues 130 | .SH AUTHORS 131 | 132 | Eric Davis (edavis@insanum.com) 133 | 134 | Brian Hartvigsen (brian.andrew@brianandjenny.com) 135 | 136 | Joshua Crowgey (jcrowgey@uw.edu) 137 | -------------------------------------------------------------------------------- /gcalcli/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from ._version import __version__ as __version__ 3 | except ImportError: 4 | import warnings 5 | warnings.warn('Failed to load __version__ from setuptools-scm') 6 | __version__ = '__unknown__' 7 | 8 | __program__ = 'gcalcli' 9 | -------------------------------------------------------------------------------- /gcalcli/_types.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """_types.py: types for type inference.""" 3 | 4 | # must have underscore so as not to shadow stdlib types.py 5 | 6 | from datetime import datetime 7 | from typing import Any, TYPE_CHECKING, TypedDict 8 | 9 | if TYPE_CHECKING: 10 | from googleapiclient._apis.calendar.v3.schemas import ( # type: ignore 11 | CalendarListEntry, 12 | Event as GoogleEvent 13 | ) 14 | 15 | class Event(GoogleEvent): 16 | """GoogleEvent, extended with some convenience attributes.""" 17 | 18 | gcalcli_cal: CalendarListEntry 19 | s: datetime 20 | e: datetime 21 | 22 | # XXX: having all_cals available as an invariant would be better than 23 | # setting total=False 24 | class Cache(TypedDict, total=False): 25 | all_cals: list[CalendarListEntry] 26 | else: 27 | CalendarListEntry = dict[str, Any] 28 | Event = dict[str, Any] 29 | Cache = dict[str, Any] 30 | -------------------------------------------------------------------------------- /gcalcli/actions.py: -------------------------------------------------------------------------------- 1 | """Handlers for specific agendaupdate actions.""" 2 | 3 | from .details import FIELD_HANDLERS, FIELDNAMES_READONLY 4 | from .exceptions import ReadonlyError 5 | 6 | CONFERENCE_DATA_VERSION = 1 7 | 8 | 9 | def _iter_field_handlers(row): 10 | for fieldname, value in row.items(): 11 | handler = FIELD_HANDLERS[fieldname] 12 | yield fieldname, handler, value 13 | 14 | 15 | def _check_writable_fields(row): 16 | """Check no potentially conflicting fields for a writing action.""" 17 | keys = row.keys() 18 | 19 | # XXX: instead of preventing use of end_date/end_time and length in the 20 | # same input, use successively complex conflict resolution plans: 21 | # 22 | # 1. allow it as long as they don't conflict by row 23 | # 2. conflict resolution by option 24 | # 3. conflict resolution interactively 25 | 26 | if 'length' in keys and ('end_date' in keys or 'end_time' in keys): 27 | raise NotImplementedError 28 | 29 | 30 | def patch(row, cal, interface): 31 | """Patch event with new data.""" 32 | event_id = row['id'] 33 | if not event_id: 34 | return insert(row, cal, interface) 35 | 36 | curr_event = None 37 | mod_event = {} 38 | cal_id = cal['id'] 39 | 40 | _check_writable_fields(row) 41 | 42 | for fieldname, handler, value in _iter_field_handlers(row): 43 | if fieldname in FIELDNAMES_READONLY: 44 | # Instead of changing mod_event, the Handler.patch() for 45 | # a readonly field checks against the current values. 46 | 47 | if curr_event is None: 48 | curr_event = interface._retry_with_backoff( 49 | interface.get_events() 50 | .get( 51 | calendarId=cal_id, 52 | eventId=event_id 53 | ) 54 | ) 55 | 56 | handler.patch(cal, curr_event, fieldname, value) 57 | else: 58 | handler.patch(cal, mod_event, fieldname, value) 59 | 60 | interface._retry_with_backoff( 61 | interface.get_events() 62 | .patch( 63 | calendarId=cal_id, 64 | eventId=event_id, 65 | conferenceDataVersion=CONFERENCE_DATA_VERSION, 66 | body=mod_event 67 | ) 68 | ) 69 | 70 | 71 | def insert(row, cal, interface): 72 | """Insert new event.""" 73 | event = {} 74 | cal_id = cal['id'] 75 | 76 | _check_writable_fields(row) 77 | 78 | for fieldname, handler, value in _iter_field_handlers(row): 79 | if fieldname in FIELDNAMES_READONLY: 80 | raise ReadonlyError("Cannot specify value on insert.") 81 | 82 | handler.patch(cal, event, fieldname, value) 83 | 84 | interface._retry_with_backoff( 85 | interface.get_events() 86 | .insert( 87 | calendarId=cal_id, 88 | conferenceDataVersion=CONFERENCE_DATA_VERSION, 89 | body=event 90 | ) 91 | ) 92 | 93 | 94 | def delete(row, cal, interface): 95 | """Delete event.""" 96 | cal_id = cal['id'] 97 | event_id = row['id'] 98 | 99 | interface.delete(cal_id, event_id) 100 | 101 | 102 | def ignore(*args, **kwargs): 103 | """Do nothing.""" 104 | 105 | 106 | ACTIONS = {"patch", "insert", "delete", "ignore"} 107 | -------------------------------------------------------------------------------- /gcalcli/argparsers.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import argparse 4 | import copy as _copy 5 | import datetime 6 | import locale 7 | import pathlib 8 | import sys 9 | from shutil import get_terminal_size 10 | 11 | import argcomplete # type: ignore 12 | 13 | import gcalcli 14 | 15 | from . import config, env, utils 16 | from .deprecations import DeprecatedStoreTrue, parser_allow_deprecated 17 | from .details import DETAILS 18 | from .printer import valid_color_name 19 | 20 | PROGRAM_OPTIONS = { 21 | '--client-id': {'default': None, 'type': str, 'help': 'API client_id'}, 22 | '--client-secret': { 23 | 'default': None, 24 | 'type': str, 25 | 'help': 'API client_secret', 26 | }, 27 | '--noauth_local_server': { 28 | 'action': 'store_false', 29 | 'dest': 'auth_local_server', 30 | 'help': 'Provide instructions for authenticating from a remote system ' 31 | 'using port forwarding.\nNote: Previously this option invoked an ' 32 | '"Out-Of-Band" variant of the auth flow, but that deprecated mechanism ' 33 | 'is no longer supported.', 34 | }, 35 | '--config-folder': { 36 | 'default': utils.shorten_path(env.config_dir()), 37 | 'type': pathlib.Path, 38 | 'help': 'Optional directory used to load config files. Deprecated: ' 39 | 'prefer $GCALCLI_CONFIG.', 40 | }, 41 | '--noincluderc': { 42 | 'action': 'store_false', 43 | 'dest': 'includeRc', 44 | 'help': 'Whether to include ~/.gcalclirc.', 45 | }, 46 | '--calendar': { 47 | 'default': [], 48 | 'type': str, 49 | 'action': 'append', 50 | 'dest': 'global_calendars', 51 | 'help': 'Which calendars to use, in the format "CalendarName" or ' 52 | '"CalendarName#color".\nSupported here globally for compatibility ' 53 | 'purposes, but prefer passing to individual commands after the command ' 54 | 'name since this global version is brittle.', 55 | }, 56 | '--default-calendar': { 57 | 'default': [], 58 | 'type': str, 59 | 'action': 'append', 60 | 'dest': 'default_calendars', 61 | 'help': 'Optional default calendar to use if no --calendar options are ' 62 | 'given', 63 | }, 64 | '--locale': {'default': '', 'type': str, 'help': 'System locale'}, 65 | '--refresh': { 66 | 'action': 'store_true', 67 | 'dest': 'refresh_cache', 68 | 'default': False, 69 | 'help': 'Delete and refresh cached data', 70 | }, 71 | '--nocache': { 72 | 'action': 'store_false', 73 | 'dest': 'use_cache', 74 | 'default': True, 75 | 'help': 'Execute command without using cache', 76 | }, 77 | '--conky': { 78 | 'action': 'store_true', 79 | 'default': False, 80 | 'help': 'Use Conky color codes', 81 | }, 82 | '--nocolor': { 83 | 'action': 'store_false', 84 | 'default': True, 85 | 'dest': 'color', 86 | 'help': 'Enable/Disable all color output', 87 | }, 88 | '--lineart': { 89 | 'default': 'fancy', 90 | 'choices': ['fancy', 'unicode', 'ascii'], 91 | 'help': 'Choose line art style for calendars: "fancy": for VTcodes, ' 92 | '"unicode" for Unicode box drawing characters, "ascii" for old-school ' 93 | 'plusses, hyphens and pipes.', 94 | }, 95 | } 96 | 97 | 98 | class DetailsAction(argparse._AppendAction): 99 | def __call__(self, parser, namespace, value, option_string=None): 100 | details = _copy.copy(getattr(namespace, self.dest, {})) 101 | 102 | if value == 'all': 103 | details.update({d: True for d in DETAILS}) 104 | else: 105 | details[value] = True 106 | 107 | setattr(namespace, self.dest, details) 108 | 109 | 110 | def validwidth(value): 111 | minwidth=30 112 | ival = int(value) 113 | if ival < minwidth: 114 | raise argparse.ArgumentTypeError( 115 | 'Width must be a number >= %d' % minwidth 116 | ) 117 | return ival 118 | 119 | 120 | def validreminder(value): 121 | if not utils.parse_reminder(value): 122 | raise argparse.ArgumentTypeError( 123 | 'Not a valid reminder string: %s' % value 124 | ) 125 | else: 126 | return value 127 | 128 | 129 | def get_details_parser(): 130 | details_parser = argparse.ArgumentParser(add_help=False) 131 | details_parser.add_argument( 132 | '--details', 133 | default={}, 134 | action=DetailsAction, 135 | choices=DETAILS + ['all'], 136 | help='Which parts to display, can be: ' + ', '.join(DETAILS), 137 | ) 138 | return details_parser 139 | 140 | 141 | def locale_has_24_hours(): 142 | t = datetime.time(20) 143 | try: 144 | formatted = t.strftime(locale.nl_langinfo(locale.T_FMT)) 145 | return '20' in formatted 146 | except AttributeError: 147 | # Some locales don't support nl_langinfo (see #481) 148 | return False 149 | 150 | 151 | def get_auto_width(): 152 | return get_terminal_size().columns 153 | 154 | 155 | def get_calendars_parser(nargs_multiple: bool) -> argparse.ArgumentParser: 156 | calendar_parser = argparse.ArgumentParser(add_help=False) 157 | plural = 's' if nargs_multiple else '' 158 | calendar_help = ( 159 | f'Which calendar{plural} to use, in the format "CalendarName" ' 160 | 'or "CalendarName#color", where the #color suffix is the name of a ' 161 | 'valid ANSI color (such as "brightblue").' 162 | ) 163 | if nargs_multiple: 164 | calendar_parser.add_argument( 165 | '--calendar', 166 | action='append', 167 | dest='calendars', 168 | type=str, 169 | default=[], 170 | help=f'{calendar_help} This option may be called multiple times to ' 171 | 'display additional calendars.', 172 | ) 173 | else: 174 | calendar_parser.add_argument( 175 | '--calendar', 176 | action='store', 177 | dest='calendar', 178 | type=str, 179 | default=None, 180 | help=calendar_help, 181 | ) 182 | return calendar_parser 183 | 184 | 185 | def get_output_parser(parents=[]): 186 | output_parser = argparse.ArgumentParser(add_help=False, parents=parents) 187 | output_parser.add_argument( 188 | '--tsv', 189 | action='store_true', 190 | dest='tsv', 191 | default=False, 192 | help='Use Tab Separated Value output', 193 | ) 194 | output_parser.add_argument( 195 | '--json', 196 | action='store_true', 197 | dest='json', 198 | default=False, 199 | help='Use JSON output', 200 | ) 201 | output_parser.add_argument( 202 | '--nostarted', 203 | action='store_true', 204 | dest='ignore_started', 205 | default=False, 206 | help='Hide events that have started', 207 | ) 208 | output_parser.add_argument( 209 | '--nodeclined', 210 | action='store_true', 211 | dest='ignore_declined', 212 | default=False, 213 | help='Hide events that have been declined', 214 | ) 215 | output_parser.add_argument( 216 | '--width', 217 | '-w', 218 | default=get_auto_width(), 219 | dest='width', 220 | type=validwidth, 221 | help='Set output width', 222 | ) 223 | has_24_hours = locale_has_24_hours() 224 | output_parser.add_argument( 225 | '--military', 226 | action='store_true', 227 | default=has_24_hours, 228 | help='Use 24 hour display', 229 | ) 230 | output_parser.add_argument( 231 | '--no-military', 232 | action='store_false', 233 | default=has_24_hours, 234 | help='Use 12 hour display', 235 | dest='military', 236 | ) 237 | output_parser.add_argument( 238 | '--override-color', 239 | action='store_true', 240 | default=False, 241 | help='Use overridden color for event', 242 | ) 243 | return output_parser 244 | 245 | 246 | @parser_allow_deprecated(name='color') 247 | def get_color_parser(): 248 | color_parser = argparse.ArgumentParser(add_help=False) 249 | 250 | COLOR_PARSER_OPTIONS = [ 251 | ('owner', 'cyan', 'Color for owned calendars'), 252 | ('writer', 'cyan', 'Color for writeable calendars'), 253 | ('reader', 'magenta', 'Color for read-only calendars'), 254 | ('freebusy', 'default', 'Color for free/busy calendars'), 255 | ('date', 'yellow', 'Color for the date'), 256 | ('now-marker', 'brightred', 'Color for the now marker'), 257 | ('border', 'white', 'Color of line borders'), 258 | ('title', 'brightyellow', 'Color of the agenda column titles'), 259 | ] 260 | 261 | for arg, color, msg in COLOR_PARSER_OPTIONS: 262 | arg = '--color-' + arg 263 | color_parser.add_argument( 264 | arg, default=color, type=valid_color_name, help=msg 265 | ) 266 | 267 | return color_parser 268 | 269 | 270 | @parser_allow_deprecated(name='remind') 271 | def get_remind_parser(): 272 | remind_parser = argparse.ArgumentParser(add_help=False) 273 | remind_parser.add_argument( 274 | '--reminder', 275 | default=[], 276 | type=validreminder, 277 | dest='reminders', 278 | action='append', 279 | help='Reminders in the form "TIME METH" or "TIME". TIME ' 280 | 'is a number which may be followed by an optional ' 281 | '"w", "d", "h", or "m" (meaning weeks, days, hours, ' 282 | 'minutes) and default to minutes. METH is a string ' 283 | '"popup", "email", or "sms" and defaults to popup.', 284 | ) 285 | remind_parser.add_argument( 286 | '--default-reminders', 287 | action='store_true', 288 | dest='default_reminders', 289 | default=False, 290 | help='If no --reminder is given, use the defaults. If this is ' 291 | 'false, do not create any reminders.', 292 | ) 293 | return remind_parser 294 | 295 | 296 | def get_cal_query_parser(): 297 | cal_query_parser = argparse.ArgumentParser(add_help=False) 298 | cal_query_parser.add_argument('start', type=str, nargs='?') 299 | cal_query_parser.add_argument( 300 | '--monday', 301 | action='store_const', 302 | const=config.WeekStart.MONDAY, 303 | dest='week_start', 304 | # Note defaults to SUNDAY via config.OutputSection (not explicitly set 305 | # here because that would override value from config). 306 | help='Start the week on Monday', 307 | ) 308 | cal_query_parser.add_argument( 309 | '--noweekend', 310 | action='store_false', 311 | dest='cal_weekend', 312 | default=True, 313 | help='Hide Saturday and Sunday', 314 | ) 315 | return cal_query_parser 316 | 317 | 318 | def get_updates_parser(): 319 | updates_parser = argparse.ArgumentParser(add_help=False) 320 | updates_parser.add_argument('since', type=utils.get_time_from_str) 321 | updates_parser.add_argument( 322 | 'start', type=utils.get_time_from_str, nargs='?' 323 | ) 324 | updates_parser.add_argument('end', type=utils.get_time_from_str, nargs='?') 325 | return updates_parser 326 | 327 | 328 | def get_conflicts_parser(): 329 | # optional search text, start and end filters 330 | conflicts_parser = argparse.ArgumentParser(add_help=False) 331 | conflicts_parser.add_argument('text', nargs='?', type=str) 332 | conflicts_parser.add_argument( 333 | 'start', type=utils.get_time_from_str, nargs='?' 334 | ) 335 | conflicts_parser.add_argument( 336 | 'end', type=utils.get_time_from_str, nargs='?' 337 | ) 338 | return conflicts_parser 339 | 340 | 341 | def get_start_end_parser(): 342 | se_parser = argparse.ArgumentParser(add_help=False) 343 | se_parser.add_argument('start', type=utils.get_time_from_str, nargs='?') 344 | se_parser.add_argument('end', type=utils.get_time_from_str, nargs='?') 345 | return se_parser 346 | 347 | 348 | def get_search_parser(): 349 | # requires search text, optional start and end filters 350 | search_parser = argparse.ArgumentParser(add_help=False) 351 | search_parser.add_argument('text', nargs=1) 352 | search_parser.add_argument('start', type=utils.get_time_from_str, nargs='?') 353 | search_parser.add_argument('end', type=utils.get_time_from_str, nargs='?') 354 | return search_parser 355 | 356 | 357 | def handle_unparsed(unparsed, namespace): 358 | # Attempt a reparse against the program options. 359 | # Provides some robustness for misplaced global options 360 | 361 | # make a new parser with only the global opts 362 | parser = argparse.ArgumentParser() 363 | for option, definition in PROGRAM_OPTIONS.items(): 364 | parser.add_argument(option, **definition) 365 | 366 | return parser.parse_args(unparsed, namespace=namespace) 367 | 368 | 369 | class RawDescArgDefaultsHelpFormatter( 370 | argparse.RawDescriptionHelpFormatter, argparse.ArgumentDefaultsHelpFormatter 371 | ): 372 | pass 373 | 374 | 375 | DESCRIPTION = """\ 376 | Google Calendar Command Line Interface 377 | 378 | configuration: 379 | %(prog)s supports a few other configuration mechanisms in addition to 380 | the command-line arguments listed below. 381 | 382 | $GCALCLI_CONFIG={config_path} 383 | Path to user config directory or file. 384 | Note: you can place an 'oauth' file in this config directory to 385 | support using different accounts per config. 386 | 387 | {config_file} 388 | A toml config file where some general-purpose settings can be 389 | configured. 390 | Schema: 391 | https://raw.githubusercontent.com/insanum/gcalcli/HEAD/data/config-schema.json 392 | 393 | gcalclirc @ {rc_paths} 394 | A flag file listing additional command-line args to always pass, 395 | one per line. 396 | Note: Use this sparingly and prefer other configuration mechanisms 397 | where available. This flag file mechanism can be brittle 398 | (example: https://github.com/insanum/gcalcli/issues/513). 399 | """ 400 | 401 | 402 | @parser_allow_deprecated(name='program') 403 | def get_argument_parser(): 404 | # Shorten path to ~/PATH if possible for easier readability. 405 | config_dir = utils.shorten_path(env.config_dir()) 406 | config_path = utils.shorten_path(env.explicit_config_path() or config_dir) 407 | rc_paths = [config_dir.joinpath('gcalclirc')] 408 | legacy_rc_path = pathlib.Path.home().joinpath('.gcalclirc') 409 | if legacy_rc_path.exists(): 410 | rc_paths.append(utils.shorten_path(legacy_rc_path)) 411 | 412 | calendars_parser = get_calendars_parser(nargs_multiple=True) 413 | # Variant for commands that only accept a single --calendar. 414 | calendar_parser = get_calendars_parser(nargs_multiple=False) 415 | 416 | parser = argparse.ArgumentParser( 417 | description=DESCRIPTION.format( 418 | config_path=config_path, 419 | config_file=utils.shorten_path(env.config_file()), 420 | rc_paths=', '.join(str(p) for p in rc_paths), 421 | ), 422 | formatter_class=RawDescArgDefaultsHelpFormatter, 423 | fromfile_prefix_chars='@', 424 | ) 425 | 426 | parser.add_argument( 427 | '--version', 428 | action='version', 429 | version=f'%(prog)s {gcalcli.__version__}', 430 | ) 431 | 432 | # Program level options 433 | for option, definition in PROGRAM_OPTIONS.items(): 434 | parser.add_argument(option, **definition) 435 | 436 | # parent parser types used for subcommands 437 | details_parser = get_details_parser() 438 | color_parser = get_color_parser() 439 | 440 | # Output parser should imply color parser 441 | output_parser = get_output_parser(parents=[color_parser]) 442 | 443 | remind_parser = get_remind_parser() 444 | cal_query_parser = get_cal_query_parser() 445 | updates_parser = get_updates_parser() 446 | conflicts_parser = get_conflicts_parser() 447 | 448 | # parsed start and end times 449 | start_end_parser = get_start_end_parser() 450 | 451 | # tacks on search text 452 | search_parser = get_search_parser() 453 | 454 | sub = parser.add_subparsers( 455 | help='Invoking a subcommand with --help prints subcommand usage.', 456 | dest='command', 457 | required=True, 458 | ) 459 | 460 | sub.add_parser( 461 | 'init', 462 | help='initialize authentication, etc', 463 | description='Set up (or refresh) authentication with Google Calendar', 464 | ) 465 | 466 | sub.add_parser( 467 | 'list', 468 | parents=[calendars_parser, color_parser], 469 | help='list available calendars', 470 | description='List available calendars.', 471 | ) 472 | 473 | sub.add_parser( 474 | 'search', 475 | parents=[ 476 | calendars_parser, 477 | details_parser, 478 | output_parser, 479 | search_parser, 480 | ], 481 | help='search for events within an optional time period', 482 | description='Provides case insensitive search for calendar ' 'events.', 483 | ) 484 | sub.add_parser( 485 | 'edit', 486 | parents=[ 487 | calendars_parser, 488 | details_parser, 489 | output_parser, 490 | search_parser, 491 | ], 492 | help='edit calendar events', 493 | description='Case insensitive search for items to find and edit ' 494 | 'interactively.', 495 | ) 496 | 497 | delete = sub.add_parser( 498 | 'delete', 499 | parents=[calendars_parser, output_parser, search_parser], 500 | help='delete events from the calendar', 501 | description='Case insensitive search for items to delete ' 502 | 'interactively.', 503 | ) 504 | delete.add_argument( 505 | '--iamaexpert', action='store_true', help='Probably not' 506 | ) 507 | 508 | sub.add_parser( 509 | 'agenda', 510 | parents=[ 511 | calendars_parser, 512 | details_parser, 513 | output_parser, 514 | start_end_parser, 515 | ], 516 | help='get an agenda for a time period', 517 | description='Get an agenda for a time period.', 518 | ) 519 | 520 | agendaupdate = sub.add_parser( 521 | 'agendaupdate', 522 | parents=[calendar_parser], 523 | help='update calendar from agenda TSV file', 524 | description='Update calendar from agenda TSV file.', 525 | ) 526 | agendaupdate.add_argument( 527 | 'file', 528 | type=argparse.FileType('r', errors='replace'), 529 | nargs='?', 530 | default=sys.stdin, 531 | ) 532 | 533 | sub.add_parser( 534 | 'updates', 535 | parents=[ 536 | calendars_parser, 537 | details_parser, 538 | output_parser, 539 | updates_parser, 540 | ], 541 | help='get updates since a datetime for a time period ' 542 | '(defaults to through end of current month)', 543 | description='Get updates since a datetime for a time period ' 544 | '(default to through end of current month).', 545 | ) 546 | 547 | sub.add_parser( 548 | 'conflicts', 549 | parents=[ 550 | calendars_parser, 551 | details_parser, 552 | output_parser, 553 | conflicts_parser, 554 | ], 555 | help='find event conflicts', 556 | description='Find conflicts between events matching search term ' 557 | '(default from now through 30 days into futures)', 558 | ) 559 | 560 | calw = sub.add_parser( 561 | 'calw', 562 | parents=[ 563 | calendars_parser, 564 | details_parser, 565 | output_parser, 566 | cal_query_parser, 567 | ], 568 | help='get a week-based agenda in calendar format', 569 | description='Get a week-based agenda in calendar format.', 570 | ) 571 | calw.add_argument('weeks', type=int, default=1, nargs='?') 572 | 573 | sub.add_parser( 574 | 'calm', 575 | parents=[ 576 | calendars_parser, 577 | details_parser, 578 | output_parser, 579 | cal_query_parser, 580 | ], 581 | help='get a month agenda in calendar format', 582 | description='Get a month agenda in calendar format.', 583 | ) 584 | 585 | quick = sub.add_parser( 586 | 'quick', 587 | parents=[calendar_parser, details_parser, remind_parser], 588 | help='quick-add an event to a calendar', 589 | description='`quick-add\' an event to a calendar. A single ' 590 | '--calendar must be specified.', 591 | ) 592 | quick.add_argument('text') 593 | 594 | add = sub.add_parser( 595 | 'add', 596 | parents=[calendar_parser, details_parser, remind_parser], 597 | help='add a detailed event to the calendar', 598 | description='Add an event to the calendar. Some or all metadata ' 599 | 'can be passed as options (see optional arguments). If ' 600 | 'incomplete, will drop to an interactive prompt requesting ' 601 | 'remaining data.', 602 | ) 603 | add.add_argument( 604 | '--color', 605 | dest='event_color', 606 | default=None, 607 | type=str, 608 | help='Color of event in browser (overrides default). Choose ' 609 | 'from lavender, sage, grape, flamingo, banana, tangerine, ' 610 | 'peacock, graphite, blueberry, basil, tomato.', 611 | ) 612 | add.add_argument('--title', default=None, type=str, help='Event title') 613 | add.add_argument( 614 | '--who', 615 | default=[], 616 | type=str, 617 | action='append', 618 | help='Event participant (may be provided multiple times)', 619 | ) 620 | add.add_argument('--where', default=None, type=str, help='Event location') 621 | add.add_argument('--when', default=None, type=str, help='Event time') 622 | # Allow either --duration or --end, but not both. 623 | end_group = add.add_mutually_exclusive_group() 624 | end_group.add_argument( 625 | '--duration', 626 | default=None, 627 | type=int, 628 | help='Event duration in minutes (or days if --allday is given). ' 629 | 'Alternative to --end.', 630 | ) 631 | end_group.add_argument( 632 | '--end', 633 | default=None, 634 | type=str, 635 | help='Event ending time. Alternative to --duration.', 636 | ) 637 | add.add_argument( 638 | '--description', default=None, type=str, help='Event description' 639 | ) 640 | add.add_argument( 641 | '--allday', 642 | action='store_true', 643 | dest='allday', 644 | default=False, 645 | help='If --allday is given, the event will be an all-day event ' 646 | '(possibly multi-day if --duration is greater than 1). The time part ' 647 | 'of the --when will be ignored.', 648 | ) 649 | add.add_argument( 650 | '--noprompt', 651 | action='store_false', 652 | dest='prompt', 653 | default=True, 654 | help='Don\'t prompt for missing data when adding events', 655 | ) 656 | 657 | _import = sub.add_parser( 658 | 'import', 659 | parents=[calendar_parser, remind_parser], 660 | help='import an ics/vcal file to a calendar', 661 | description='Import from an ics/vcal file; a single --calendar ' 662 | 'must be specified. Reads from stdin when no file argument is ' 663 | 'provided.', 664 | ) 665 | _import.add_argument( 666 | 'file', 667 | type=argparse.FileType('r', errors='replace'), 668 | nargs='?', 669 | default=None, 670 | ) 671 | _import.add_argument( 672 | '--verbose', '-v', action='count', help='Be verbose on imports' 673 | ) 674 | _import.add_argument( 675 | '--dump', 676 | '-d', 677 | action='store_true', 678 | help='Print events and don\'t import', 679 | ) 680 | _import.add_argument( 681 | '--use-legacy-import', 682 | action='store_true', 683 | help='Use legacy "insert" operation instead of new graceful "import" ' 684 | 'operation when importing calendar events. Note this option will be ' 685 | 'removed in future releases.', 686 | ) 687 | 688 | default_cmd = 'notify-send -u critical -i appointment-soon -a gcalcli %s' 689 | remind = sub.add_parser( 690 | 'remind', 691 | parents=[calendars_parser], 692 | help='execute command if event occurs within time', 693 | description='Execute if event occurs within ; the %s ' 694 | 'in is replaced with event start time and title text.' 695 | 'default command: "' + default_cmd + '"', 696 | ) 697 | remind.add_argument('minutes', nargs='?', type=int, default=10) 698 | remind.add_argument('cmd', nargs='?', type=str, default=default_cmd) 699 | 700 | remind.add_argument( 701 | '--use-reminders', 702 | action='store_true', 703 | help='Honor the remind time when running remind command', 704 | ) 705 | 706 | remind.add_argument( 707 | '--use_reminders', action=DeprecatedStoreTrue, help=argparse.SUPPRESS 708 | ) 709 | 710 | config = sub.add_parser( 711 | 'config', 712 | help='utility commands to work with configuration', 713 | ) 714 | config_sub = config.add_subparsers( 715 | dest='subcommand', 716 | required=True, 717 | ) 718 | 719 | config_sub.add_parser( 720 | 'edit', 721 | help='launch config.toml in a text editor', 722 | ) 723 | 724 | util = sub.add_parser( 725 | 'util', 726 | help='low-level utility commands for introspection, dumping schemas, ' 727 | 'etc', 728 | ) 729 | util_sub = util.add_subparsers( 730 | dest='subcommand', 731 | required=True, 732 | ) 733 | 734 | util_sub.add_parser( 735 | 'config-schema', 736 | help='print the JSON schema for the gcalcli TOML config format', 737 | ) 738 | util_sub.add_parser( 739 | 'reset-cache', 740 | help='manually erase the cache', 741 | description="Delete gcalcli's internal cache file as a workaround for " 742 | "caching bugs like insanum/gcalcli#622", 743 | ) 744 | util_sub.add_parser( 745 | 'inspect-auth', 746 | help='show metadata about auth token', 747 | description="Dump metadata about the saved auth token gcalcli is set " 748 | "up to use for you", 749 | ) 750 | 751 | # Enrich with argcomplete options. 752 | argcomplete.autocomplete(parser) 753 | 754 | return parser 755 | -------------------------------------------------------------------------------- /gcalcli/auth.py: -------------------------------------------------------------------------------- 1 | from contextlib import closing 2 | import socket 3 | from google.auth.transport.requests import Request 4 | from google_auth_oauthlib.flow import InstalledAppFlow 5 | from google.oauth2.credentials import Credentials 6 | from gcalcli.printer import Printer 7 | 8 | 9 | def authenticate( 10 | client_id: str, client_secret: str, printer: Printer, local: bool 11 | ): 12 | flow = InstalledAppFlow.from_client_config( 13 | client_config={ 14 | "installed": { 15 | "client_id": client_id, 16 | "client_secret": client_secret, 17 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 18 | "token_uri": "https://oauth2.googleapis.com/token", 19 | "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", 20 | "redirect_uris": ["http://localhost"], 21 | } 22 | }, 23 | scopes=["https://www.googleapis.com/auth/calendar"], 24 | ) 25 | if not local: 26 | printer.msg( 27 | 'Note: Behavior of the `--noauth-local-server` option has changed! ' 28 | 'Starting local server, but providing instructions for connecting ' 29 | 'to it remotely...\n' 30 | ) 31 | credentials = None 32 | attempt_num = 0 33 | # Retry up to 5 attempts with different random ports. 34 | while credentials is None: 35 | port = _free_local_port() 36 | if not local: 37 | printer.msg('Option 1 (outbound):\n', 'yellow') 38 | printer.msg( 39 | ' To establish a connection from this system to a remote ' 40 | 'host, execute a command like: `ssh username@host -L ' 41 | f'{port}:localhost:{port} BROWSER=open $BROWSER ' 42 | "'https://the-url-below'`\n", 43 | ) 44 | printer.msg('Option 2 (outbound):\n', 'yellow') 45 | printer.msg( 46 | ' To establish a connection from a remote host to this ' 47 | 'system, execute a command from remote host like: ' 48 | f'`ssh username@host -fN -R {port}:localhost:{port} ; ' 49 | "BROWSER=open $BROWSER https://the-url-below'`\n\n", 50 | ) 51 | try: 52 | credentials = flow.run_local_server(open_browser=False, port=port) 53 | except OSError as e: 54 | if e.errno == 98 and attempt_num < 4: 55 | # Will get retried with a different port. 56 | printer.msg(f'Port {port} in use, trying another port...') 57 | attempt_num += 1 58 | else: 59 | raise 60 | except RecursionError: 61 | raise OSError( 62 | 'Failed to fetch credentials. If this is a nonstandard gcalcli ' 63 | 'install, please try again with a system-installed gcalcli as ' 64 | 'a workaround.\n' 65 | 'Details: https://github.com/insanum/gcalcli/issues/735.' 66 | ) 67 | return credentials 68 | 69 | 70 | def _free_local_port(): 71 | # See https://stackoverflow.com/a/45690594. 72 | with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: 73 | s.bind(('', 0)) 74 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 75 | return s.getsockname()[1] 76 | 77 | 78 | def refresh_if_expired(credentials) -> None: 79 | if credentials.expired: 80 | credentials.refresh(Request()) 81 | 82 | 83 | def creds_from_legacy_json(data): 84 | kwargs = { 85 | k: v 86 | for k, v in data.items() 87 | if k 88 | in ( 89 | 'client_id', 90 | 'client_secret', 91 | 'refresh_token', 92 | 'token_uri', 93 | 'scopes', 94 | ) 95 | } 96 | return Credentials(data['access_token'], **kwargs) 97 | -------------------------------------------------------------------------------- /gcalcli/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # PYTHON_ARGCOMPLETE_OK 3 | # 4 | # ######################################################################### # 5 | # # 6 | # ( ( ( # 7 | # ( ( ( )\ ) ( )\ ) )\ ) # 8 | # )\ ) )\ )\ (()/( )\ (()/( (()/( # 9 | # (()/( (((_)((((_)( /(_))(((_) /(_)) /(_)) # 10 | # /(_))_ )\___ )\ _ )\ (_)) )\___ (_)) (_)) # 11 | # (_)) __|((/ __|(_)_\(_)| | ((/ __|| | |_ _| # 12 | # | (_ | | (__ / _ \ | |__ | (__ | |__ | | # 13 | # \___| \___|/_/ \_\ |____| \___||____||___| # 14 | # # 15 | # Authors: Eric Davis # 16 | # Brian Hartvigsen # 17 | # Joshua Crowgey # 18 | # Maintainers: David Barnett # 19 | # Home: https://github.com/insanum/gcalcli # 20 | # # 21 | # Everything you need to know (Google API Calendar v3): http://goo.gl/HfTGQ # 22 | # # 23 | # ######################################################################### # 24 | 25 | 26 | # Import trusted certificate store to enable SSL, e.g., behind firewalls. 27 | # Must be called as early as possible to avoid bugs. 28 | # fmt: off 29 | import truststore; truststore.inject_into_ssl() # noqa: I001,E702 30 | # fmt: on 31 | # ruff: noqa: E402 32 | 33 | 34 | import json 35 | import os 36 | import pathlib 37 | import re 38 | import signal 39 | import sys 40 | from argparse import ArgumentTypeError 41 | from collections import namedtuple 42 | 43 | from . import config, env, utils 44 | from .argparsers import get_argument_parser, handle_unparsed 45 | from .exceptions import GcalcliError 46 | from .gcal import GoogleCalendarInterface 47 | from .printer import Printer, valid_color_name 48 | from .validators import ( 49 | DATE_INPUT_DESCRIPTION, 50 | PARSABLE_DATE, 51 | PARSABLE_DURATION, 52 | REMINDER, 53 | STR_ALLOW_EMPTY, 54 | STR_NOT_EMPTY, 55 | get_input, 56 | ) 57 | 58 | CalName = namedtuple('CalName', ['name', 'color']) 59 | 60 | EMPTY_CONFIG_TOML = """\ 61 | #:schema https://raw.githubusercontent.com/insanum/gcalcli/HEAD/data/config-schema.json 62 | 63 | """ 64 | 65 | 66 | def rsplit_unescaped_hash(string): 67 | # Use regex to find parts before/after last unescaped hash separator. 68 | # Sadly, all the "proper solutions" are even more questionable: 69 | # https://stackoverflow.com/questions/4020539/process-escape-sequences 70 | match = re.match( 71 | r"""(?x) 72 | ^((?:\\.|[^\\])*) 73 | [#] 74 | ((?:\\.|[^#\\])*)$ 75 | """, 76 | string 77 | ) 78 | if not match: 79 | return (string, None) 80 | # Unescape and return (part1, part2) 81 | return tuple(re.sub(r'\\(.)', r'\1', p) 82 | for p in match.group(1, 2)) 83 | 84 | 85 | def parse_cal_names(cal_names: list[str], printer: Printer): 86 | cal_colors = {} 87 | for name in cal_names: 88 | cal_color = 'default' 89 | p1, p2 = rsplit_unescaped_hash(name) 90 | if p2 is not None: 91 | try: 92 | name, cal_color = p1, valid_color_name(p2) 93 | except ArgumentTypeError: 94 | printer.debug_msg( 95 | f'Using entire name {name!r} as cal name.\n' 96 | f'Change {p1!r} to a valid color name if intended to be a ' 97 | 'color (or otherwise consider escaping "#" chars to "\\#").' 98 | '\n' 99 | ) 100 | 101 | cal_colors[name] = cal_color 102 | return [CalName(name=k, color=v) for k, v in cal_colors.items()] 103 | 104 | 105 | def run_add_prompt(parsed_args, printer): 106 | if not any( 107 | a is None 108 | for a in ( 109 | parsed_args.title, 110 | parsed_args.where, 111 | parsed_args.when, 112 | parsed_args.duration or parsed_args.end, 113 | parsed_args.description, 114 | parsed_args.reminders or parsed_args.default_reminders, 115 | ) 116 | ): 117 | return 118 | printer.msg( 119 | 'Prompting for unfilled values.\n' 120 | 'Run with --noprompt to leave them unfilled without prompting.\n' 121 | ) 122 | if parsed_args.title is None: 123 | parsed_args.title = get_input(printer, 'Title: ', STR_NOT_EMPTY) 124 | if parsed_args.where is None: 125 | parsed_args.where = get_input(printer, 'Location: ', STR_ALLOW_EMPTY) 126 | if parsed_args.when is None: 127 | parsed_args.when = get_input( 128 | printer, 129 | 'When (? for help): ', 130 | PARSABLE_DATE, 131 | help=f'Expected format: {DATE_INPUT_DESCRIPTION}', 132 | ) 133 | if parsed_args.duration is None and parsed_args.end is None: 134 | if parsed_args.allday: 135 | prompt = 'Duration (days): ' 136 | else: 137 | prompt = 'Duration (human readable): ' 138 | parsed_args.duration = get_input(printer, prompt, PARSABLE_DURATION) 139 | if parsed_args.description is None: 140 | parsed_args.description = get_input( 141 | printer, 'Description: ', STR_ALLOW_EMPTY 142 | ) 143 | if not parsed_args.reminders and not parsed_args.default_reminders: 144 | while True: 145 | r = get_input( 146 | printer, 'Enter a valid reminder or ' '"." to end: ', REMINDER 147 | ) 148 | 149 | if r == '.': 150 | break 151 | n, m = utils.parse_reminder(str(r)) 152 | parsed_args.reminders.append(str(n) + ' ' + m) 153 | 154 | 155 | def main(): 156 | parser = get_argument_parser() 157 | argv = sys.argv[1:] 158 | 159 | rc_paths = [ 160 | pathlib.Path('~/.gcalclirc').expanduser(), 161 | env.config_dir().joinpath('gcalclirc'), 162 | ] 163 | # Note: Order is significant here, so precedence is 164 | # ~/.gcalclirc < CONFIGDIR/gcalclirc < explicit args 165 | fromfile_args = [f'@{rc}' for rc in rc_paths if rc.exists()] 166 | 167 | try: 168 | (parsed_args, unparsed) = parser.parse_known_args(fromfile_args + argv) 169 | except Exception as e: 170 | sys.stderr.write(str(e)) 171 | parser.print_usage() 172 | sys.exit(1) 173 | 174 | if parsed_args.config_folder: 175 | parsed_args.config_folder = parsed_args.config_folder.expanduser() 176 | # Re-evaluate rc_paths in case --config-folder or something was updated. 177 | # Note this could resolve strangely if you e.g. have a gcalclirc file that 178 | # contains --noincluderc or overrides --config-folder from inside config 179 | # folder. If that causes problems... don't do that. 180 | rc_paths = [ 181 | pathlib.Path('~/.gcalclirc').expanduser(), 182 | parsed_args.config_folder.joinpath('gcalclirc') 183 | if parsed_args.config_folder 184 | else None, 185 | ] 186 | fromfile_args = [f'@{rc}' for rc in rc_paths if rc and rc.exists()] 187 | 188 | config_filepath = env.config_file() 189 | if config_filepath.exists(): 190 | with config_filepath.open('rb') as config_file: 191 | opts_from_config = config.Config.from_toml(config_file) 192 | else: 193 | opts_from_config = config.Config() 194 | 195 | namespace_from_config = opts_from_config.to_argparse_namespace() 196 | # Pull week_start aside and set it manually after parse_known_args. 197 | # TODO: Figure out why week_start from opts_from_config getting through. 198 | week_start = namespace_from_config.week_start 199 | namespace_from_config.week_start = None 200 | if parsed_args.includeRc: 201 | argv = fromfile_args + argv 202 | (parsed_args, unparsed) = parser.parse_known_args( 203 | argv, namespace=namespace_from_config 204 | ) 205 | if parsed_args.week_start is None: 206 | parsed_args.week_start = week_start 207 | if parsed_args.config_folder: 208 | parsed_args.config_folder = parsed_args.config_folder.expanduser() 209 | 210 | printer = Printer( 211 | conky=parsed_args.conky, use_color=parsed_args.color, 212 | art_style=parsed_args.lineart 213 | ) 214 | 215 | if unparsed: 216 | try: 217 | parsed_args = handle_unparsed(unparsed, parsed_args) 218 | except Exception as e: 219 | sys.stderr.write(str(e)) 220 | parser.print_usage() 221 | sys.exit(1) 222 | 223 | if parsed_args.locale: 224 | try: 225 | utils.set_locale(parsed_args.locale) 226 | except ValueError as exc: 227 | printer.err_msg(str(exc)) 228 | 229 | cal_names = set_resolved_calendars(parsed_args, printer=printer) 230 | 231 | userless_mode = bool(os.environ.get('GCALCLI_USERLESS_MODE')) 232 | if parsed_args.command in ('config', 'util'): 233 | gcal = None 234 | else: 235 | gcal = GoogleCalendarInterface( 236 | cal_names=cal_names, 237 | printer=printer, 238 | userless_mode=userless_mode, 239 | # TODO: Avoid heavy unnecessary setup in general, remove override. 240 | do_eager_init=parsed_args.command != 'init', 241 | **vars(parsed_args), 242 | ) 243 | 244 | try: 245 | if parsed_args.command == 'init': 246 | gcal.SetupAuth() 247 | 248 | elif parsed_args.command == 'list': 249 | gcal.ListAllCalendars() 250 | 251 | elif parsed_args.command == 'agenda': 252 | gcal.AgendaQuery(start=parsed_args.start, end=parsed_args.end) 253 | 254 | elif parsed_args.command == 'agendaupdate': 255 | gcal.AgendaUpdate(parsed_args.file) 256 | 257 | elif parsed_args.command == 'updates': 258 | gcal.UpdatesQuery( 259 | last_updated_datetime=parsed_args.since, 260 | start=parsed_args.start, 261 | end=parsed_args.end) 262 | 263 | elif parsed_args.command == 'conflicts': 264 | gcal.ConflictsQuery( 265 | search_text=parsed_args.text, 266 | start=parsed_args.start, 267 | end=parsed_args.end) 268 | 269 | elif parsed_args.command == 'calw': 270 | gcal.CalQuery( 271 | parsed_args.command, count=parsed_args.weeks, 272 | start_text=parsed_args.start 273 | ) 274 | 275 | elif parsed_args.command == 'calm': 276 | gcal.CalQuery(parsed_args.command, start_text=parsed_args.start) 277 | 278 | elif parsed_args.command == 'quick': 279 | if not parsed_args.text: 280 | printer.err_msg('Error: invalid event text\n') 281 | sys.exit(1) 282 | 283 | # allow unicode strings for input 284 | gcal.QuickAddEvent( 285 | parsed_args.text, reminders=parsed_args.reminders 286 | ) 287 | 288 | elif parsed_args.command == 'add': 289 | if parsed_args.prompt: 290 | run_add_prompt(parsed_args, printer) 291 | 292 | # calculate "when" time: 293 | try: 294 | estart, eend = utils.get_times_from_duration( 295 | parsed_args.when, 296 | duration=parsed_args.duration, 297 | end=parsed_args.end, 298 | allday=parsed_args.allday) 299 | except ValueError as exc: 300 | printer.err_msg(str(exc)) 301 | # Since we actually need a valid start and end time in order to 302 | # add the event, we cannot proceed. 303 | raise 304 | 305 | gcal.AddEvent(parsed_args.title, parsed_args.where, estart, eend, 306 | parsed_args.description, parsed_args.who, 307 | parsed_args.reminders, parsed_args.event_color) 308 | 309 | elif parsed_args.command == 'search': 310 | gcal.TextQuery( 311 | parsed_args.text[0], start=parsed_args.start, 312 | end=parsed_args.end 313 | ) 314 | 315 | elif parsed_args.command == 'delete': 316 | gcal.ModifyEvents( 317 | gcal._delete_event, parsed_args.text[0], 318 | start=parsed_args.start, end=parsed_args.end, 319 | expert=parsed_args.iamaexpert 320 | ) 321 | 322 | elif parsed_args.command == 'edit': 323 | gcal.ModifyEvents( 324 | gcal._edit_event, parsed_args.text[0], 325 | start=parsed_args.start, end=parsed_args.end 326 | ) 327 | 328 | elif parsed_args.command == 'remind': 329 | gcal.Remind( 330 | parsed_args.minutes, parsed_args.cmd, 331 | use_reminders=parsed_args.use_reminders 332 | ) 333 | 334 | elif parsed_args.command == 'import': 335 | gcal.ImportICS( 336 | parsed_args.verbose, parsed_args.dump, 337 | parsed_args.reminders, parsed_args.file 338 | ) 339 | 340 | elif parsed_args.command == 'config': 341 | if parsed_args.subcommand == 'edit': 342 | printer.msg( 343 | f'Launching {utils.shorten_path(config_filepath)} in a ' 344 | 'text editor...\n' 345 | ) 346 | if not config_filepath.exists(): 347 | config_filepath.parent.mkdir(parents=True, exist_ok=True) 348 | with open(config_filepath, 'w') as f: 349 | f.write(EMPTY_CONFIG_TOML) 350 | utils.launch_editor(config_filepath) 351 | 352 | elif parsed_args.command == 'util': 353 | if parsed_args.subcommand == 'config-schema': 354 | printer.debug_msg( 355 | 'Outputting schema for config.toml files. This can be ' 356 | 'saved to a file and used in a directive like ' 357 | '#:schema my-schema.json\n' 358 | ) 359 | schema = config.Config.json_schema() 360 | print(json.dumps(schema, indent=2)) 361 | elif parsed_args.subcommand == 'reset-cache': 362 | deleted_something = False 363 | for (cache_filepath, _) in env.data_file_paths( 364 | 'cache', parsed_args.config_folder 365 | ): 366 | if cache_filepath.exists(): 367 | printer.msg( 368 | f'Deleting cache file from {cache_filepath}...\n' 369 | ) 370 | cache_filepath.unlink(missing_ok=True) 371 | deleted_something = True 372 | if not deleted_something: 373 | printer.msg( 374 | 'No cache file found. Exiting without deleting ' 375 | 'anything...\n' 376 | ) 377 | elif parsed_args.subcommand == 'inspect-auth': 378 | auth_data = utils.inspect_auth() 379 | for k, v in auth_data.items(): 380 | printer.msg(f"{k}: {v}\n") 381 | if auth_data.get('format', 'unknown') != 'unknown': 382 | printer.msg( 383 | "\n" 384 | "The grant's entry under " 385 | "https://myaccount.google.com/connections should also " 386 | "list creation time and other info Google provides on " 387 | "the access grant.\n" 388 | 'Hint: filter by "Access to: Calendar" if you have ' 389 | "trouble finding the right one.\n") 390 | else: 391 | printer.err_msg("No existing auth token found\n") 392 | 393 | except GcalcliError as exc: 394 | printer.err_msg(str(exc)) 395 | sys.exit(1) 396 | 397 | 398 | def set_resolved_calendars(parsed_args, printer: Printer) -> list[str]: 399 | multiple_allowed = not hasattr(parsed_args, 'calendar') 400 | 401 | # Reflect .calendar into .calendars (as list). 402 | if hasattr(parsed_args, 'calendar') and not hasattr( 403 | parsed_args, 'calendars' 404 | ): 405 | parsed_args.calendars = ( 406 | [parsed_args.calendar] if parsed_args.calendar else [] 407 | ) 408 | # If command didn't request calendar or calendars, bail out with empty list. 409 | # Note: this means if you forget parents=[calendar_parser] on a subparser, 410 | # you'll hit this case and any global/default cals will be ignored. 411 | if not hasattr(parsed_args, 'calendars'): 412 | return [] 413 | 414 | if not parsed_args.calendars: 415 | for cals_type, cals in [ 416 | ('global calendars', parsed_args.global_calendars), 417 | ('default-calendars', parsed_args.default_calendars), 418 | ]: 419 | if len(cals) > 1 and not multiple_allowed: 420 | printer.debug_msg( 421 | f"Can't use multiple {cals_type} for command " 422 | f"`{parsed_args.command}`. Must select --calendar " 423 | "explicitly.\n" 424 | ) 425 | continue 426 | if cals: 427 | parsed_args.calendars = cals 428 | break 429 | elif len(parsed_args.calendars) > 1 and not multiple_allowed: 430 | printer.err_msg( 431 | 'Multiple target calendars specified! Please only pass a ' 432 | 'single --calendar if you want it to be used.\n' 433 | ) 434 | printer.msg( 435 | 'Note: consider using --noincluderc if additional ' 436 | 'calendars may be coming from gcalclirc.\n' 437 | ) 438 | 439 | cal_names = parse_cal_names(parsed_args.calendars, printer=printer) 440 | # Only ignore calendars if they're not explicitly in --calendar list. 441 | parsed_args.ignore_calendars[:] = [ 442 | c 443 | for c in parsed_args.ignore_calendars 444 | if c not in [c2.name for c2 in cal_names] 445 | ] 446 | 447 | return cal_names 448 | 449 | 450 | def SIGINT_handler(signum, frame): 451 | sys.stderr.write('Signal caught, bye!\n') 452 | sys.exit(1) 453 | 454 | 455 | signal.signal(signal.SIGINT, SIGINT_handler) 456 | 457 | 458 | if __name__ == '__main__': 459 | main() 460 | -------------------------------------------------------------------------------- /gcalcli/config.py: -------------------------------------------------------------------------------- 1 | """Helpers and data objects for gcalcli configuration.""" 2 | 3 | import argparse 4 | from collections import OrderedDict 5 | from enum import Enum 6 | import sys 7 | from typing import Any, List, Mapping, Optional 8 | 9 | if sys.version_info[:2] < (3, 11): 10 | import tomli as tomllib 11 | else: 12 | import tomllib 13 | 14 | from pydantic import BaseModel, ConfigDict, Field 15 | from pydantic.json_schema import GenerateJsonSchema 16 | 17 | 18 | class AuthSection(BaseModel): 19 | """Configuration for settings like client-id used in auth flow. 20 | 21 | Note that client-secret can't be configured here and should be passed on 22 | command-line instead for security reasons. 23 | """ 24 | 25 | model_config = ConfigDict(title='Settings for authentication') 26 | 27 | client_id: Optional[str] = Field( 28 | alias='client-id', 29 | title='Client ID for Google auth token', 30 | description="""Google client ID for your auth client project.\n 31 | Details: https://github.com/insanum/gcalcli/blob/HEAD/docs/api-auth.md""", 32 | default=None, 33 | ) 34 | 35 | 36 | class CalendarsSection(BaseModel): 37 | model_config = ConfigDict( 38 | title='Settings about the set of calendars gcalcli operates on' 39 | ) 40 | 41 | default_calendars: List[str] = Field( 42 | alias='default-calendars', 43 | title='Calendars to use as default for certain commands when no \ 44 | explicit target calendar is otherwise specified', 45 | default_factory=list, 46 | ) 47 | 48 | ignore_calendars: List[str] = Field( 49 | alias='ignore-calendars', 50 | title='Calendars to ignore by default (unless explicitly included \ 51 | using --calendar) when running commands against all calendars', 52 | default_factory=list, 53 | ) 54 | 55 | 56 | class WeekStart(str, Enum): 57 | SUNDAY = "sunday" 58 | MONDAY = "monday" 59 | 60 | 61 | class OutputSection(BaseModel): 62 | model_config = ConfigDict( 63 | title='Settings about gcalcli output (formatting, colors, etc)' 64 | ) 65 | 66 | week_start: WeekStart = Field( 67 | alias='week-start', 68 | title='Weekday to treat as start of week', 69 | default=WeekStart.SUNDAY, 70 | ) 71 | 72 | 73 | class Config(BaseModel): 74 | """User configuration for gcalcli command-line tool. 75 | 76 | See https://pypi.org/project/gcalcli/. 77 | """ 78 | 79 | model_config = ConfigDict( 80 | title='gcalcli config', 81 | json_schema_extra={'$schema': GenerateJsonSchema.schema_dialect}, 82 | ) 83 | 84 | auth: AuthSection = Field(default_factory=AuthSection) 85 | calendars: CalendarsSection = Field(default_factory=CalendarsSection) 86 | output: OutputSection = Field(default_factory=OutputSection) 87 | 88 | @classmethod 89 | def from_toml(cls, config_file): 90 | config = tomllib.load(config_file) 91 | return cls(**config) 92 | 93 | def to_argparse_namespace(self) -> argparse.Namespace: 94 | kwargs = {} 95 | if self.auth: 96 | kwargs.update(vars(self.auth)) 97 | if self.calendars: 98 | kwargs.update(vars(self.calendars)) 99 | if self.output: 100 | kwargs.update(vars(self.output)) 101 | return argparse.Namespace(**kwargs) 102 | 103 | @classmethod 104 | def json_schema(cls) -> Mapping[str, Any]: 105 | schema = super().model_json_schema() 106 | return schema_entity_ordered(schema) 107 | 108 | 109 | def schema_entity_ordered(entity: Mapping[str, Any]) -> Mapping[str, Any]: 110 | """A copy of JSON schema data reordered into more tidy logical ordering.""" 111 | ordered = OrderedDict() 112 | keys = set(entity.keys()) 113 | for k in ('$schema', 'title', 'description', 'type', 'items'): 114 | if k in keys: 115 | keys.remove(k) 116 | ordered[k] = entity[k] 117 | if '$defs' in keys: 118 | keys.remove('$defs') 119 | ordered['$defs'] = OrderedDict( 120 | (k, schema_entity_ordered(v)) for k, v in entity['$defs'].items() 121 | ) 122 | if 'properties' in keys: 123 | keys.remove('properties') 124 | ordered['properties'] = OrderedDict( 125 | (k, schema_entity_ordered(v)) 126 | for k, v in entity['properties'].items() 127 | ) 128 | # Include remaining fields in arbitrary order. 129 | for k in sorted(keys): 130 | ordered[k] = entity[k] 131 | return ordered 132 | -------------------------------------------------------------------------------- /gcalcli/conflicts.py: -------------------------------------------------------------------------------- 1 | from ._types import Event 2 | 3 | 4 | class ShowConflicts: 5 | active_events: list[Event] = [] 6 | 7 | def __init__(self, show): 8 | if show: 9 | self.show = show 10 | else: 11 | self.show = self._default_show 12 | 13 | def show_conflicts(self, latest_event): 14 | """Events must be passed in chronological order""" 15 | start = latest_event['s'] 16 | for event in self.active_events: 17 | if (event['e'] > start): 18 | self.show(event) 19 | self.active_events = list( 20 | filter(lambda e: e['e'] > start, self.active_events)) 21 | self.active_events.append(latest_event) 22 | 23 | def _default_show(self, e): 24 | print(e) 25 | -------------------------------------------------------------------------------- /gcalcli/deprecations.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import functools 3 | from typing import Any 4 | 5 | from .printer import Printer, valid_color_name 6 | 7 | printer = Printer() 8 | 9 | CAMELS = {"--configFolder": "--config-folder", 10 | "--defaultCalendar": "--default-calendar"} 11 | 12 | 13 | def warn_deprecated_opt(option_string): 14 | suggestion = 'Please use "{}", instead.\n' 15 | 16 | suggestion = (suggestion.format(CAMELS[option_string]) 17 | if option_string in CAMELS 18 | else suggestion.format(option_string.replace('_', '-'))) 19 | 20 | msg = ('WARNING: {} has been deprecated and will be removed in a future ' 21 | 'release.\n' + suggestion) 22 | printer.err_msg(msg.format(option_string)) 23 | 24 | 25 | class DeprecatedStore(argparse._StoreAction): 26 | def __call__( 27 | self, parser, namespace, values, option_string=None, **kwargs): 28 | warn_deprecated_opt(option_string) 29 | setattr(namespace, self.dest, values) 30 | 31 | 32 | class DeprecatedStoreTrue(argparse._StoreConstAction): 33 | 34 | def __init__(self, 35 | option_strings, 36 | dest, 37 | default=False, 38 | required=False, 39 | help=None): 40 | super(DeprecatedStoreTrue, self).__init__( 41 | option_strings=option_strings, 42 | dest=dest, 43 | const=True, 44 | default=default, 45 | required=required, 46 | help=help) 47 | 48 | def __call__(self, parser, namespace, values, option_string=None): 49 | warn_deprecated_opt(option_string) 50 | setattr(namespace, self.dest, self.const) 51 | 52 | 53 | class DeprecatedAppend(argparse._AppendAction): 54 | def __call__(self, parser, namespace, values, option_string=None): 55 | warn_deprecated_opt(option_string) 56 | items = argparse._copy.copy( 57 | argparse._ensure_value(namespace, self.dest, [])) 58 | items.append(values) 59 | setattr(namespace, self.dest, items) 60 | 61 | 62 | BASE_OPTS = {'program': {'type': str, 63 | 'help': argparse.SUPPRESS, 64 | 'action': DeprecatedStore}, 65 | 'color': {'type': valid_color_name, 66 | 'help': argparse.SUPPRESS, 67 | 'action': DeprecatedStore}, 68 | 'remind': {'help': argparse.SUPPRESS, 69 | 'action': DeprecatedStoreTrue}} 70 | 71 | 72 | OPTIONS: dict[str, dict[str, Any]] = { 73 | 'program': { 74 | "--client_id": {'default': None}, 75 | "--client_secret": {'default': None}, 76 | "--configFolder": {'default': None}, 77 | "--defaultCalendar": {'default': [], 'action': DeprecatedAppend} 78 | }, 79 | 'color': { 80 | "--color_owner": {"default": "cyan"}, 81 | "--color_writer": {"default": "cyan"}, 82 | "--color_reader": {"default": "magenta"}, 83 | "--color_freebusy": {"default": "default"}, 84 | "--color_date": {"default": "yellow"}, 85 | "--color_now_marker": {"default": "brightred"}, 86 | "--color_border": {"default": "white"}, 87 | "--color_title": {"default": "brightyellow"}, 88 | }, 89 | 'remind': { 90 | '--default_reminders': {'action': DeprecatedStoreTrue, 91 | 'default': False}} 92 | } 93 | 94 | 95 | def parser_allow_deprecated(getter_func=None, name=None): 96 | if callable(getter_func): 97 | @functools.wraps(getter_func) 98 | def wrapped(*args, **kwargs): 99 | parser = getter_func() 100 | for arg, options in OPTIONS[name].items(): 101 | parser.add_argument( 102 | arg, default=options['default'], **BASE_OPTS[name]) 103 | return parser 104 | return wrapped 105 | 106 | else: 107 | def partial_parser_allow_deprecated(getter_func): 108 | return parser_allow_deprecated(getter_func, name=name) 109 | return partial_parser_allow_deprecated 110 | 111 | 112 | ALL_DEPRECATED_OPTS = {} 113 | ALL_DEPRECATED_OPTS.update(OPTIONS['program']) 114 | ALL_DEPRECATED_OPTS.update(OPTIONS['color']) 115 | ALL_DEPRECATED_OPTS.update(OPTIONS['remind']) 116 | -------------------------------------------------------------------------------- /gcalcli/details.py: -------------------------------------------------------------------------------- 1 | """Handlers for specific details of events.""" 2 | 3 | from collections import OrderedDict 4 | from datetime import datetime 5 | from itertools import chain 6 | 7 | from dateutil.parser import isoparse, parse 8 | 9 | from .exceptions import ReadonlyCheckError, ReadonlyError 10 | from .utils import get_timedelta_from_str, is_all_day 11 | 12 | FMT_DATE = '%Y-%m-%d' 13 | FMT_TIME = '%H:%M' 14 | TODAY = datetime.now().date() 15 | ACTION_DEFAULT = 'patch' 16 | 17 | URL_PROPS = OrderedDict([('html_link', 'htmlLink'), 18 | ('hangout_link', 'hangoutLink')]) 19 | ENTRY_POINT_PROPS = OrderedDict([('conference_entry_point_type', 20 | 'entryPointType'), 21 | ('conference_uri', 'uri')]) 22 | 23 | 24 | def _valid_title(event): 25 | if 'summary' in event and event['summary'].strip(): 26 | return event['summary'] 27 | else: 28 | return '(No title)' 29 | 30 | 31 | class Handler: 32 | """Handler for a specific detail of an event.""" 33 | 34 | # list of strings for fieldnames provided by this object 35 | fieldnames: list[str] = [] 36 | 37 | @classmethod 38 | def get(cls, event): 39 | """Return simple string representation for columnar output.""" 40 | raise NotImplementedError 41 | 42 | @classmethod 43 | def data(cls, event): 44 | """Return plain data for formatted output.""" 45 | return NotImplementedError 46 | 47 | @classmethod 48 | def patch(cls, cal, event, fieldname, value): 49 | """Patch event from value.""" 50 | raise NotImplementedError 51 | 52 | 53 | class SingleFieldHandler(Handler): 54 | """Handler for a detail that only produces one column.""" 55 | 56 | @classmethod 57 | def get(cls, event): 58 | return [cls._get(event).strip()] 59 | 60 | @classmethod 61 | def data(cls, event): 62 | return cls._get(event).strip() 63 | 64 | @classmethod 65 | def patch(cls, cal, event, fieldname, value): 66 | return cls._patch(event, value) 67 | 68 | 69 | class SimpleSingleFieldHandler(SingleFieldHandler): 70 | """Handler for single-string details that require no special processing.""" 71 | 72 | @classmethod 73 | def _get(cls, event): 74 | return event.get(cls.fieldnames[0], '') 75 | 76 | @classmethod 77 | def _patch(cls, event, value): 78 | event[cls.fieldnames[0]] = value 79 | 80 | 81 | class Time(Handler): 82 | """Handler for dates and times.""" 83 | 84 | fieldnames = ['start_date', 'start_time', 'end_date', 'end_time'] 85 | 86 | @classmethod 87 | def _datetime_to_fields(cls, instant, all_day): 88 | instant_date = instant.strftime(FMT_DATE) 89 | 90 | if all_day: 91 | instant_time = '' 92 | else: 93 | instant_time = instant.strftime(FMT_TIME) 94 | 95 | return [instant_date, instant_time] 96 | 97 | @classmethod 98 | def get(cls, event): 99 | all_day = is_all_day(event) 100 | 101 | start_fields = cls._datetime_to_fields(event['s'], all_day) 102 | end_fields = cls._datetime_to_fields(event['e'], all_day) 103 | 104 | return start_fields + end_fields 105 | 106 | @classmethod 107 | def data(cls, event): 108 | return dict(zip(cls.fieldnames, cls.get(event))) 109 | 110 | @classmethod 111 | def patch(cls, cal, event, fieldname, value): 112 | instant_name, _, unit = fieldname.partition('_') 113 | 114 | assert instant_name in {'start', 'end'} 115 | 116 | if unit == 'date': 117 | instant = event[instant_name] = {} 118 | instant_date = parse(value, default=TODAY) 119 | 120 | instant['date'] = instant_date.isoformat() 121 | instant['dateTime'] = None # clear any previous non-all-day time 122 | else: 123 | assert unit == 'time' 124 | 125 | # If the time field is empty, do nothing. 126 | # This enables all day events. 127 | if not value.strip(): 128 | return 129 | 130 | # date must be an earlier TSV field than time 131 | instant = event[instant_name] 132 | instant_date = isoparse(instant['date']) 133 | instant_datetime = parse(value, default=instant_date) 134 | 135 | instant['date'] = None # clear all-day date 136 | instant['dateTime'] = instant_datetime.isoformat() 137 | instant['timeZone'] = cal['timeZone'] 138 | 139 | 140 | class Length(Handler): 141 | """Handler for event duration.""" 142 | 143 | fieldnames = ['length'] 144 | 145 | @classmethod 146 | def get(cls, event): 147 | return [str(event['e'] - event['s'])] 148 | 149 | @classmethod 150 | def data(cls, event): 151 | return str(event['e'] - event['s']) 152 | 153 | @classmethod 154 | def patch(cls, cal, event, fieldname, value): 155 | # start_date and start_time must be an earlier TSV field than length 156 | start = event['start'] 157 | end = event['end'] = {} 158 | 159 | if start['date']: 160 | # XXX: handle all-day events 161 | raise NotImplementedError 162 | 163 | start_datetime = isoparse(start['dateTime']) 164 | end_datetime = start_datetime + get_timedelta_from_str(value) 165 | 166 | end['date'] = None # clear all-day date, for good measure 167 | end['dateTime'] = end_datetime.isoformat() 168 | end['timeZone'] = cal['timeZone'] 169 | 170 | 171 | class Url(Handler): 172 | """Handler for HTML and legacy Hangout links.""" 173 | 174 | fieldnames = list(URL_PROPS.keys()) 175 | 176 | @classmethod 177 | def get(cls, event): 178 | return [event.get(prop, '').strip() for prop in URL_PROPS.values()] 179 | 180 | @classmethod 181 | def data(cls, event): 182 | return {key: event.get(prop, '').strip() 183 | for key, prop in URL_PROPS.items()} 184 | 185 | @classmethod 186 | def patch(cls, cal, event, fieldname, value): 187 | if fieldname == 'html_link': 188 | raise ReadonlyError(fieldname, 189 | 'It is not possible to verify that the value ' 190 | 'has not changed. ' 191 | 'Remove it from the input.') 192 | 193 | prop = URL_PROPS[fieldname] 194 | 195 | # Fail if the current value doesn't 196 | # match the desired patch. This requires an additional API query for 197 | # each row, so best to avoid attempting to update these fields. 198 | 199 | curr_value = event.get(prop, '') 200 | 201 | if curr_value != value: 202 | raise ReadonlyCheckError(fieldname, curr_value, value) 203 | 204 | 205 | class Conference(Handler): 206 | """Handler for videoconference and teleconference details.""" 207 | 208 | fieldnames = list(ENTRY_POINT_PROPS.keys()) 209 | 210 | CONFERENCE_PROPS = OrderedDict([('meeting_code', 'meetingCode'), 211 | ('passcode', 'passcode'), 212 | ('region_code', 'regionCode')]) 213 | 214 | @classmethod 215 | def get(cls, event): 216 | if 'conferenceData' not in event: 217 | return ['', ''] 218 | 219 | data = event['conferenceData'] 220 | 221 | # only display first entry point for TSV 222 | # https://github.com/insanum/gcalcli/issues/533 223 | entry_point = data['entryPoints'][0] 224 | 225 | return [entry_point.get(prop, '') 226 | for prop in ENTRY_POINT_PROPS.values()] 227 | 228 | @classmethod 229 | def data(cls, event): 230 | if 'conferenceData' not in event: 231 | return [] 232 | 233 | PROPS = {**ENTRY_POINT_PROPS, **cls.CONFERENCE_PROPS} 234 | 235 | value = [] 236 | for entryPoint in event['conferenceData'].get('entryPoints', []): 237 | value.append({key: entryPoint.get(prop, '').strip() 238 | for key, prop in PROPS.items()}) 239 | return value 240 | 241 | @classmethod 242 | def patch(cls, cal, event, fieldname, value): 243 | if not value: 244 | return 245 | 246 | prop = ENTRY_POINT_PROPS[fieldname] 247 | 248 | data = event.setdefault('conferenceData', {}) 249 | entry_points = data.setdefault('entryPoints', []) 250 | if not entry_points: 251 | entry_points.append({}) 252 | 253 | entry_point = entry_points[0] 254 | entry_point[prop] = value 255 | 256 | 257 | class Attendees(Handler): 258 | """Handler for event attendees.""" 259 | 260 | fieldnames = ['attendees'] 261 | 262 | ATTENDEE_PROPS = \ 263 | OrderedDict([('attendee_email', 'email'), 264 | ('attendee_response_status', 'responseStatus')]) 265 | 266 | @classmethod 267 | def get(cls, event): 268 | if 'attendees' not in event: 269 | return [''] 270 | 271 | # only display the attendee emails for TSV 272 | return [';'.join([attendee.get('email', '').strip() 273 | for attendee in event['attendees']])] 274 | 275 | @classmethod 276 | def data(cls, event): 277 | value = [] 278 | for attendee in event.get('attendees', []): 279 | value.append({key: attendee.get(prop, '').strip() 280 | for key, prop in cls.ATTENDEE_PROPS.items()}) 281 | return value 282 | 283 | @classmethod 284 | def patch(cls, cal, event, fieldname, value): 285 | if not value: 286 | return 287 | 288 | attendees = event.setdefault('attendees', []) 289 | if not attendees: 290 | attendees.append({}) 291 | 292 | attendee = attendees[0] 293 | attendee[fieldname] = value 294 | 295 | 296 | class Title(SingleFieldHandler): 297 | """Handler for title.""" 298 | 299 | fieldnames = ['title'] 300 | 301 | @classmethod 302 | def _get(cls, event): 303 | return _valid_title(event) 304 | 305 | @classmethod 306 | def _patch(cls, event, value): 307 | event['summary'] = value 308 | 309 | 310 | class Location(SimpleSingleFieldHandler): 311 | """Handler for location.""" 312 | 313 | fieldnames = ['location'] 314 | 315 | 316 | class Description(SimpleSingleFieldHandler): 317 | """Handler for description.""" 318 | 319 | fieldnames = ['description'] 320 | 321 | 322 | class Calendar(SingleFieldHandler): 323 | """Handler for calendar.""" 324 | 325 | fieldnames = ['calendar'] 326 | 327 | @classmethod 328 | def _get(cls, event): 329 | return event['gcalcli_cal']['summary'] 330 | 331 | @classmethod 332 | def patch(cls, cal, event, fieldname, value): 333 | curr_value = cal['summary'] 334 | 335 | if curr_value != value: 336 | raise ReadonlyCheckError(fieldname, curr_value, value) 337 | 338 | 339 | class Email(SingleFieldHandler): 340 | """Handler for emails.""" 341 | 342 | fieldnames = ['email'] 343 | 344 | @classmethod 345 | def _get(cls, event): 346 | if 'organizer' in event: 347 | return event['organizer'].get('email', '') 348 | if 'creator' in event: 349 | return event['creator'].get('email', '') 350 | return event['gcalcli_cal']['id'] 351 | 352 | 353 | class ID(SimpleSingleFieldHandler): 354 | """Handler for event ID.""" 355 | 356 | fieldnames = ['id'] 357 | 358 | 359 | class Action(SingleFieldHandler): 360 | """Handler specifying event processing during an update.""" 361 | 362 | fieldnames = ['action'] 363 | 364 | @classmethod 365 | def _get(cls, event): 366 | return ACTION_DEFAULT 367 | 368 | 369 | HANDLERS = OrderedDict([('id', ID), 370 | ('time', Time), 371 | ('length', Length), 372 | ('url', Url), 373 | ('conference', Conference), 374 | ('title', Title), 375 | ('location', Location), 376 | ('description', Description), 377 | ('calendar', Calendar), 378 | ('email', Email), 379 | ('attendees', Attendees), 380 | ('action', Action)]) 381 | HANDLERS_READONLY = {Url, Calendar} 382 | 383 | FIELD_HANDLERS = dict(chain.from_iterable( 384 | (((fieldname, handler) 385 | for fieldname in handler.fieldnames) 386 | for handler in HANDLERS.values()))) 387 | 388 | FIELDNAMES_READONLY = frozenset(fieldname 389 | for fieldname, handler 390 | in FIELD_HANDLERS.items() 391 | if handler in HANDLERS_READONLY) 392 | 393 | _DETAILS_WITHOUT_HANDLERS = ['reminders', 'attachments', 'end'] 394 | 395 | DETAILS = list(HANDLERS.keys()) + _DETAILS_WITHOUT_HANDLERS 396 | DETAILS_DEFAULT = {'time', 'title'} 397 | -------------------------------------------------------------------------------- /gcalcli/env.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | from typing import Optional, Tuple 4 | 5 | import platformdirs 6 | 7 | from . import __program__ 8 | 9 | 10 | def default_data_dir() -> pathlib.Path: 11 | return platformdirs.user_data_path(__program__) 12 | 13 | 14 | def data_file_paths( 15 | name: str, 16 | config_dir: Optional[pathlib.Path] = None, 17 | ) -> list[Tuple[pathlib.Path, int]]: 18 | """Return all paths actively used for the given data file name. 19 | 20 | The paths are returned as tuples in order of decreasing precedence like: 21 | [(CONFIG/name, 1), (DATA_DIR/name, 0), (~/.gcalcli_{name}, -1)] 22 | with the DATA_DIR path always present and others only present if the file 23 | exists. 24 | """ 25 | paths = [] 26 | # Path in config dir takes precedence, if any. 27 | if config_dir: 28 | path_in_config = config_dir.joinpath(name) 29 | if path_in_config.exists(): 30 | paths.append((path_in_config, 1)) 31 | # Standard data path comes next. 32 | paths.append((default_data_dir().joinpath(name), 0)) 33 | # Lastly, fall back to legacy path if it exists and there's no config dir. 34 | legacy_path = pathlib.Path(f'~/.gcalcli_{name}').expanduser() 35 | if legacy_path.exists(): 36 | paths.append((legacy_path, -1)) 37 | return paths 38 | 39 | 40 | def explicit_config_path() -> Optional[pathlib.Path]: 41 | config_path = os.environ.get('GCALCLI_CONFIG') 42 | return pathlib.Path(config_path) if config_path else None 43 | 44 | 45 | def config_dir() -> pathlib.Path: 46 | from_env = explicit_config_path() 47 | if from_env: 48 | return from_env.parent if from_env.is_file() else from_env 49 | return pathlib.Path(platformdirs.user_config_dir(__program__)) 50 | 51 | 52 | def config_file() -> pathlib.Path: 53 | config_path = explicit_config_path() 54 | if config_path and config_path.is_file(): 55 | # Special case: $GCALCLI_CONFIG points directly to file, not necessarily 56 | # named "config.toml". 57 | return config_path 58 | if not config_path: 59 | config_path = pathlib.Path(platformdirs.user_config_dir(__program__)) 60 | return config_path.joinpath('config.toml') 61 | -------------------------------------------------------------------------------- /gcalcli/exceptions.py: -------------------------------------------------------------------------------- 1 | class GcalcliError(Exception): 2 | pass 3 | 4 | 5 | class ValidationError(Exception): 6 | def __init__(self, message): 7 | super(ValidationError, self).__init__(message) 8 | self.message = message 9 | 10 | 11 | class ReadonlyError(Exception): 12 | def __init__(self, fieldname, message): 13 | message = 'Field {} is read-only. {}'.format(fieldname, message) 14 | super(ReadonlyError, self).__init__(message) 15 | 16 | 17 | class ReadonlyCheckError(ReadonlyError): 18 | _fmt = 'Current value "{}" does not match update value "{}"' 19 | 20 | def __init__(self, fieldname, curr_value, mod_value): 21 | message = self._fmt.format(curr_value, mod_value) 22 | super(ReadonlyCheckError, self).__init__(fieldname, message) 23 | 24 | 25 | def raise_one_cal_error(cals): 26 | raise GcalcliError( 27 | 'You must only specify a single calendar\n' 28 | 'Calendars: {}\n'.format(cals) 29 | ) 30 | -------------------------------------------------------------------------------- /gcalcli/ics.py: -------------------------------------------------------------------------------- 1 | """Helpers for working with iCal/ics format.""" 2 | 3 | from dataclasses import dataclass 4 | import importlib.util 5 | import io 6 | from datetime import datetime, timedelta 7 | import pathlib 8 | import tempfile 9 | from typing import Any, NamedTuple, Optional 10 | 11 | from gcalcli.printer import Printer 12 | from gcalcli.utils import localize_datetime 13 | 14 | 15 | @dataclass 16 | class EventData: 17 | body: Optional[dict[str, Any]] 18 | source: Any 19 | 20 | def label_str(self): 21 | if getattr(self.source, 'summary'): 22 | return f'"{self.source.summary}"' 23 | elif hasattr(self.source, 'dtstart') and self.source.dtstart.value: 24 | return f"with start {self.source.dtstart.value}" 25 | else: 26 | return None 27 | 28 | 29 | class IcalData(NamedTuple): 30 | events: list[EventData] 31 | raw_components: list[Any] 32 | 33 | 34 | def has_vobject_support() -> bool: 35 | return importlib.util.find_spec('vobject') is not None 36 | 37 | 38 | def get_ics_data( 39 | ics: io.TextIOBase, verbose: bool, default_tz: str, printer: Printer 40 | ) -> IcalData: 41 | import vobject 42 | 43 | events: list[EventData] = [] 44 | raw_components: list[Any] = [] 45 | for v in vobject.readComponents(ics): 46 | if v.name == 'VCALENDAR' and hasattr(v, 'components'): 47 | raw_components.extend( 48 | c for c in v.components() if c.name != 'VEVENT' 49 | ) 50 | # Strangely, in empty calendar cases vobject sometimes returns 51 | # Components with no vevent_list attribute at all. 52 | vevents = getattr(v, 'vevent_list', []) 53 | events.extend( 54 | CreateEventFromVOBJ( 55 | ve, verbose=verbose, default_tz=default_tz, printer=printer 56 | ) 57 | for ve in vevents 58 | ) 59 | return IcalData(events, raw_components) 60 | 61 | 62 | def CreateEventFromVOBJ( 63 | ve, verbose: bool, default_tz: str, printer: Printer 64 | ) -> EventData: 65 | event = {} 66 | 67 | if verbose: 68 | print('+----------------+') 69 | print('| Calendar Event |') 70 | print('+----------------+') 71 | 72 | if hasattr(ve, 'summary'): 73 | if verbose: 74 | print('Event........%s' % ve.summary.value) 75 | event['summary'] = ve.summary.value 76 | 77 | if hasattr(ve, 'location'): 78 | if verbose: 79 | print('Location.....%s' % ve.location.value) 80 | event['location'] = ve.location.value 81 | 82 | if not hasattr(ve, 'dtstart') or not ve.dtstart.value: 83 | printer.err_msg('Error: event does not have a dtstart!\n') 84 | return EventData(body=None, source=ve) 85 | 86 | if hasattr(ve, 'rrule'): 87 | if verbose: 88 | print('Recurrence...%s' % ve.rrule.value) 89 | 90 | event['recurrence'] = ['RRULE:' + ve.rrule.value] 91 | 92 | if verbose: 93 | print('Start........%s' % ve.dtstart.value.isoformat()) 94 | print('Local Start..%s' % localize_datetime(ve.dtstart.value)) 95 | 96 | # XXX 97 | # Timezone madness! Note that we're using the timezone for the calendar 98 | # being added to. This is OK if the event is in the same timezone. This 99 | # needs to be changed to use the timezone from the DTSTART and DTEND values. 100 | # Problem is, for example, the TZID might be "Pacific Standard Time" and 101 | # Google expects a timezone string like "America/Los_Angeles". Need to find 102 | # a way in python to convert to the more specific timezone string. 103 | # XXX 104 | # print ve.dtstart.params['X-VOBJ-ORIGINAL-TZID'][0] 105 | # print default_tz 106 | # print dir(ve.dtstart.value.tzinfo) 107 | # print vars(ve.dtstart.value.tzinfo) 108 | 109 | start = ve.dtstart.value.isoformat() 110 | if isinstance(ve.dtstart.value, datetime): 111 | event['start'] = {'dateTime': start, 'timeZone': default_tz} 112 | else: 113 | event['start'] = {'date': start} 114 | 115 | # All events must have a start, but explicit end is optional. 116 | # If there is no end, use the duration if available, or the start otherwise. 117 | if hasattr(ve, 'dtend') and ve.dtend.value: 118 | end = ve.dtend.value 119 | if verbose: 120 | print('End..........%s' % end.isoformat()) 121 | print('Local End....%s' % localize_datetime(end)) 122 | else: # using duration instead of end 123 | if hasattr(ve, 'duration') and ve.duration.value: 124 | duration = ve.duration.value 125 | else: 126 | printer.msg( 127 | "Falling back to 30m duration for imported event w/o " 128 | "explicit duration or end.\n" 129 | ) 130 | duration = timedelta(minutes=30) 131 | if verbose: 132 | print('Duration.....%s' % duration) 133 | end = ve.dtstart.value + duration 134 | if verbose: 135 | print('Calculated End........%s' % end.isoformat()) 136 | print('Calculated Local End..%s' % localize_datetime(end)) 137 | 138 | if isinstance(end, datetime): 139 | event['end'] = {'dateTime': end.isoformat(), 'timeZone': default_tz} 140 | else: 141 | event['end'] = {'date': end.isoformat()} 142 | 143 | # NOTE: Reminders added by GoogleCalendarInterface caller. 144 | 145 | if hasattr(ve, 'description') and ve.description.value.strip(): 146 | descr = ve.description.value.strip() 147 | if verbose: 148 | print('Description:\n%s' % descr) 149 | event['description'] = descr 150 | 151 | if hasattr(ve, 'organizer'): 152 | if ve.organizer.value.startswith('MAILTO:'): 153 | email = ve.organizer.value[7:] 154 | else: 155 | email = ve.organizer.value 156 | if verbose: 157 | print('organizer:\n %s' % email) 158 | event['organizer'] = {'displayName': ve.organizer.name, 'email': email} 159 | 160 | if hasattr(ve, 'attendee_list'): 161 | if verbose: 162 | print('attendees:') 163 | event['attendees'] = [] 164 | for attendee in ve.attendee_list: 165 | if attendee.value.upper().startswith('MAILTO:'): 166 | email = attendee.value[7:] 167 | else: 168 | email = attendee.value 169 | if verbose: 170 | print(' %s' % email) 171 | 172 | event['attendees'].append( 173 | {'displayName': attendee.name, 'email': email} 174 | ) 175 | 176 | if hasattr(ve, 'uid'): 177 | uid = ve.uid.value.strip() 178 | if verbose: 179 | print(f'UID..........{uid}') 180 | event['iCalUID'] = uid 181 | if hasattr(ve, 'sequence'): 182 | sequence = ve.sequence.value.strip() 183 | if verbose: 184 | print(f'Sequence.....{sequence}') 185 | event['sequence'] = sequence 186 | 187 | return EventData(body=event, source=ve) 188 | 189 | 190 | def dump_partial_ical( 191 | events: list[EventData], raw_components: list[Any] 192 | ) -> pathlib.Path: 193 | import vobject 194 | 195 | tmp_dir = pathlib.Path(tempfile.mkdtemp(prefix="gcalcli.")) 196 | f_path = tmp_dir.joinpath("rej.ics") 197 | cal = vobject.iCalendar() 198 | for c in raw_components: 199 | cal.add(c) 200 | for event in events: 201 | cal.add(event.source) 202 | with open(f_path, 'w') as f: 203 | f.write(cal.serialize()) 204 | return f_path 205 | -------------------------------------------------------------------------------- /gcalcli/printer.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | 4 | COLOR_NAMES = set(('default', 'black', 'red', 'green', 'yellow', 'blue', 5 | 'magenta', 'cyan', 'white', 'brightblack', 'brightred', 6 | 'brightgreen', 'brightyellow', 'brightblue', 7 | 'brightmagenta', 'brightcyan', 'brightwhite')) 8 | ART_CHARS = { 9 | 'fancy': { 10 | 'hrz': '\033(0\x71\033(B', 11 | 'vrt': '\033(0\x78\033(B', 12 | 'lrc': '\033(0\x6A\033(B', 13 | 'urc': '\033(0\x6B\033(B', 14 | 'ulc': '\033(0\x6C\033(B', 15 | 'llc': '\033(0\x6D\033(B', 16 | 'crs': '\033(0\x6E\033(B', 17 | 'lte': '\033(0\x74\033(B', 18 | 'rte': '\033(0\x75\033(B', 19 | 'bte': '\033(0\x76\033(B', 20 | 'ute': '\033(0\x77\033(B'}, 21 | 'unicode': { 22 | 'hrz': '\u2500', 23 | 'vrt': '\u2502', 24 | 'lrc': '\u2518', 25 | 'urc': '\u2510', 26 | 'ulc': '\u250c', 27 | 'llc': '\u2514', 28 | 'crs': '\u253c', 29 | 'lte': '\u251c', 30 | 'rte': '\u2524', 31 | 'bte': '\u2534', 32 | 'ute': '\u252c'}, 33 | 'ascii': { 34 | 'hrz': '-', 35 | 'vrt': '|', 36 | 'lrc': '+', 37 | 'urc': '+', 38 | 'ulc': '+', 39 | 'llc': '+', 40 | 'crs': '+', 41 | 'lte': '+', 42 | 'rte': '+', 43 | 'bte': '+', 44 | 'ute': '+'}} 45 | 46 | 47 | def valid_color_name(value): 48 | if value not in COLOR_NAMES: 49 | raise argparse.ArgumentTypeError( 50 | f'{value} is not a valid color. ' 51 | f'Valid colours are: {", ".join(COLOR_NAMES)}') 52 | return value 53 | 54 | 55 | class Printer(object): 56 | """Provide methods for terminal output with color (or not)""" 57 | 58 | def __init__(self, conky=False, use_color=True, art_style='ascii'): 59 | self.use_color = use_color 60 | self.conky = conky 61 | self.colors = { 62 | 'default': '${color}' if conky else '\033[0m', 63 | 'black': '${color black}' if conky else '\033[0;30m', 64 | 'brightblack': '${color black}' if conky else '\033[30;1m', 65 | 'red': '${color red}' if conky else '\033[0;31m', 66 | 'brightred': '${color red}' if conky else '\033[31;1m', 67 | 'green': '${color green}' if conky else '\033[0;32m', 68 | 'brightgreen': '${color green}' if conky else '\033[32;1m', 69 | 'yellow': '${color yellow}' if conky else '\033[0;33m', 70 | 'brightyellow': '${color yellow}' if conky else '\033[33;1m', 71 | 'blue': '${color blue}' if conky else '\033[0;34m', 72 | 'brightblue': '${color blue}' if conky else '\033[34;1m', 73 | 'magenta': '${color magenta}' if conky else '\033[0;35m', 74 | 'brightmagenta': '${color magenta}' if conky else '\033[35;1m', 75 | 'cyan': '${color cyan}' if conky else '\033[0;36m', 76 | 'brightcyan': '${color cyan}' if conky else '\033[36;1m', 77 | 'white': '${color white}' if conky else '\033[0;37m', 78 | 'brightwhite': '${color white}' if conky else '\033[37;1m', 79 | None: '${color}' if conky else '\033[0m'} 80 | self.colorset = set(self.colors.keys()) 81 | 82 | self.art_style = art_style 83 | self.art = ART_CHARS[self.art_style] 84 | 85 | def get_colorcode(self, colorname): 86 | return self.colors.get(colorname, '') 87 | 88 | def msg(self, msg, colorname='default', file=sys.stdout): 89 | if self.use_color: 90 | msg = self.colors[colorname] + msg + self.colors['default'] 91 | file.write(msg) 92 | 93 | def err_msg(self, msg): 94 | self.msg(msg, 'brightred', file=sys.stderr) 95 | 96 | def debug_msg(self, msg): 97 | self.msg(msg, 'yellow', file=sys.stderr) 98 | 99 | def art_msg(self, arttag, colorname, file=sys.stdout): 100 | """Wrapper for easy emission of the calendar borders""" 101 | self.msg(self.art[arttag], colorname, file=file) 102 | -------------------------------------------------------------------------------- /gcalcli/utils.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | from collections import OrderedDict 3 | import json 4 | import locale 5 | import os 6 | import pathlib 7 | import pickle 8 | import re 9 | import subprocess 10 | import time 11 | from datetime import datetime, timedelta 12 | from typing import Any, Tuple 13 | 14 | import babel 15 | from dateutil.parser import parse as dateutil_parse 16 | from dateutil.tz import tzlocal 17 | from parsedatetime.parsedatetime import Calendar 18 | 19 | from . import auth, env 20 | 21 | locale.setlocale(locale.LC_ALL, '') 22 | fuzzy_date_parse = Calendar().parse 23 | fuzzy_datetime_parse = Calendar().parseDT 24 | 25 | 26 | REMINDER_REGEX = r'^(\d+)([wdhm]?)(?:\s+(popup|email|sms))?$' 27 | 28 | DURATION_REGEX = re.compile( 29 | r'^((?P[\.\d]+?)(?:d|day|days))?[ :]*' 30 | r'((?P[\.\d]+?)(?:h|hour|hours))?[ :]*' 31 | r'((?P[\.\d]+?)(?:m|min|mins|minute|minutes))?[ :]*' 32 | r'((?P[\.\d]+?)(?:s|sec|secs|second|seconds))?$' 33 | ) 34 | 35 | 36 | def parse_reminder(rem): 37 | match = re.match(REMINDER_REGEX, rem) 38 | if not match: 39 | # Allow argparse to generate a message when parsing options 40 | return None 41 | n = int(match.group(1)) 42 | t = match.group(2) 43 | m = match.group(3) 44 | if t == 'w': 45 | n = n * 7 * 24 * 60 46 | elif t == 'd': 47 | n = n * 24 * 60 48 | elif t == 'h': 49 | n = n * 60 50 | 51 | if not m: 52 | m = 'popup' 53 | 54 | return n, m 55 | 56 | 57 | def set_locale(new_locale): 58 | try: 59 | locale.setlocale(locale.LC_ALL, new_locale) 60 | except locale.Error as exc: 61 | raise ValueError( 62 | 'Error: ' 63 | + str(exc) 64 | + '!\n Check supported locales of your system.\n' 65 | ) 66 | 67 | 68 | def get_times_from_duration( 69 | when, duration=0, end=None, allday=False 70 | ) -> Tuple[str, str]: 71 | try: 72 | start = get_time_from_str(when) 73 | except Exception: 74 | raise ValueError('Date and time is invalid: %s\n' % (when)) 75 | 76 | if end is not None: 77 | stop = get_time_from_str(end) 78 | elif allday: 79 | try: 80 | stop = start + timedelta(days=float(duration)) 81 | except Exception: 82 | raise ValueError( 83 | 'Duration time (days) is invalid: %s\n' % (duration) 84 | ) 85 | start = start.date() 86 | stop = stop.date() 87 | else: 88 | try: 89 | stop = start + get_timedelta_from_str(duration) 90 | except Exception: 91 | raise ValueError('Duration time is invalid: %s\n' % (duration)) 92 | 93 | return start.isoformat(), stop.isoformat() 94 | 95 | 96 | def _is_dayfirst_locale(): 97 | """Detect whether system locale date format has day first. 98 | 99 | Examples: 100 | - M/d/yy -> False 101 | - dd/MM/yy -> True 102 | - (UnknownLocaleError) -> False 103 | 104 | Pattern syntax is documented at 105 | https://babel.pocoo.org/en/latest/dates.html#pattern-syntax. 106 | """ 107 | try: 108 | locale = babel.Locale(babel.default_locale('LC_TIME')) 109 | except babel.UnknownLocaleError: 110 | # Couldn't detect locale, assume non-dayfirst. 111 | return False 112 | m = re.search(r'M|d|$', locale.date_formats['short'].pattern) 113 | return m and m.group(0) == 'd' 114 | 115 | 116 | def get_time_from_str(when): 117 | """Convert a string to a time: first uses the dateutil parser, falls back 118 | on fuzzy matching with parsedatetime 119 | """ 120 | zero_oclock_today = datetime.now(tzlocal()).replace( 121 | hour=0, minute=0, second=0, microsecond=0 122 | ) 123 | 124 | # Only apply dayfirst=True if date actually starts with "XX-XX-". 125 | # Other forms like YYYY-MM-DD shouldn't rely on locale by default (#792). 126 | dayfirst = ( 127 | _is_dayfirst_locale() if re.match(r'^\d{1,2}-\d{1,2}-', when) else None 128 | ) 129 | try: 130 | event_time = dateutil_parse( 131 | when, default=zero_oclock_today, dayfirst=dayfirst 132 | ) 133 | except ValueError: 134 | struct, result = fuzzy_date_parse(when) 135 | if not result: 136 | raise ValueError('Date and time is invalid: %s' % (when)) 137 | event_time = datetime.fromtimestamp(time.mktime(struct), tzlocal()) 138 | 139 | return event_time 140 | 141 | 142 | def get_timedelta_from_str(delta): 143 | """ 144 | Parse a time string a timedelta object. 145 | Formats: 146 | - number -> duration in minutes 147 | - "1:10" -> hour and minutes 148 | - "1d 1h 1m" -> days, hours, minutes 149 | Based on https://stackoverflow.com/a/51916936/12880 150 | """ 151 | parsed_delta = None 152 | try: 153 | parsed_delta = timedelta(minutes=float(delta)) 154 | except ValueError: 155 | pass 156 | if parsed_delta is None: 157 | parts = DURATION_REGEX.match(delta) 158 | if parts is not None: 159 | try: 160 | time_params = { 161 | name: float(param) 162 | for name, param in parts.groupdict().items() 163 | if param 164 | } 165 | parsed_delta = timedelta(**time_params) 166 | except ValueError: 167 | pass 168 | if parsed_delta is None: 169 | dt, result = fuzzy_datetime_parse(delta, sourceTime=datetime.min) 170 | if result: 171 | parsed_delta = dt - datetime.min 172 | if parsed_delta is None: 173 | raise ValueError('Duration is invalid: %s' % (delta)) 174 | return parsed_delta 175 | 176 | 177 | def days_since_epoch(dt): 178 | __DAYS_IN_SECONDS__ = 24 * 60 * 60 179 | return calendar.timegm(dt.timetuple()) / __DAYS_IN_SECONDS__ 180 | 181 | 182 | def agenda_time_fmt(dt, military): 183 | hour_min_fmt = '%H:%M' if military else '%I:%M' 184 | ampm = '' if military else dt.strftime('%p').lower() 185 | return dt.strftime(hour_min_fmt).lstrip('0') + ampm 186 | 187 | 188 | def is_all_day(event): 189 | # XXX: currently gcalcli represents all-day events as those that both begin 190 | # and end at midnight. This is ambiguous with Google Calendar events that 191 | # are not all-day but happen to begin and end at midnight. 192 | 193 | return ( 194 | event['s'].hour == 0 195 | and event['s'].minute == 0 196 | and event['e'].hour == 0 197 | and event['e'].minute == 0 198 | ) 199 | 200 | 201 | def localize_datetime(dt): 202 | if not hasattr(dt, 'tzinfo'): # Why are we skipping these? 203 | return dt 204 | if dt.tzinfo is None: 205 | return dt.replace(tzinfo=tzlocal()) 206 | else: 207 | return dt.astimezone(tzlocal()) 208 | 209 | 210 | def launch_editor(path: str | os.PathLike): 211 | if hasattr(os, 'startfile'): 212 | os.startfile(path, 'edit') 213 | return 214 | for editor in ( 215 | 'editor', 216 | os.environ.get('EDITOR', None), 217 | 'xdg-open', 218 | 'open', 219 | ): 220 | if not editor: 221 | continue 222 | try: 223 | subprocess.call((editor, path)) 224 | return 225 | except OSError: 226 | pass 227 | raise OSError(f'No editor/launcher detected on your system to edit {path}') 228 | 229 | 230 | def shorten_path(path: pathlib.Path) -> pathlib.Path: 231 | """Try to shorten path using special characters like ~. 232 | 233 | Returns original path unmodified if it can't be shortened. 234 | """ 235 | tilde_home = pathlib.Path('~') 236 | expanduser_len = len(tilde_home.expanduser().parts) 237 | if path.parts[:expanduser_len] == tilde_home.expanduser().parts: 238 | return tilde_home.joinpath(*path.parts[expanduser_len:]) 239 | return path 240 | 241 | 242 | def inspect_auth() -> dict[str, Any]: 243 | auth_data: dict[str, Any] = OrderedDict() 244 | auth_path = None 245 | for (path, _) in env.data_file_paths('oauth', env.config_dir()): 246 | if path.exists(): 247 | auth_path = path 248 | auth_data['path'] = shorten_path(path) 249 | break 250 | if auth_path: 251 | with auth_path.open('rb') as gcalcli_oauth: 252 | try: 253 | creds = pickle.load(gcalcli_oauth) 254 | auth_data['format'] = 'pickle' 255 | except (pickle.UnpicklingError, EOFError): 256 | # Try reading as legacy json format as fallback. 257 | try: 258 | gcalcli_oauth.seek(0) 259 | creds = auth.creds_from_legacy_json( 260 | json.load(gcalcli_oauth) 261 | ) 262 | auth_data['format'] = 'json' 263 | except (OSError, ValueError, EOFError): 264 | pass 265 | auth_data.setdefault('format', 'unknown') 266 | if 'format' in auth_data: 267 | for k in [ 268 | 'client_id', 269 | 'scopes', 270 | 'valid', 271 | 'token_state', 272 | 'expiry', 273 | 'expired', 274 | ]: 275 | if hasattr(creds, k): 276 | auth_data[k] = getattr(creds, k) 277 | return auth_data 278 | -------------------------------------------------------------------------------- /gcalcli/validators.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Optional 3 | 4 | from .exceptions import ValidationError 5 | from .utils import ( 6 | get_time_from_str, 7 | get_timedelta_from_str, 8 | REMINDER_REGEX, 9 | ) 10 | 11 | # TODO: in the future, pull these from the API 12 | # https://developers.google.com/calendar/v3/reference/colors 13 | VALID_OVERRIDE_COLORS = ['lavender', 'sage', 'grape', 'flamingo', 14 | 'banana', 'tangerine', 'peacock', 'graphite', 15 | 'blueberry', 'basil', 'tomato'] 16 | 17 | DATE_INPUT_DESCRIPTION = '\ 18 | a date (e.g. 2019-12-31, tomorrow 10am, 2nd Jan, Jan 4th, etc) or valid time \ 19 | if today' 20 | 21 | 22 | def get_override_color_id(color): 23 | return str(VALID_OVERRIDE_COLORS.index(color) + 1) 24 | 25 | 26 | def get_input(printer, prompt, validator_func, help: Optional[str] = None): 27 | printer.msg(prompt, 'magenta') 28 | while True: 29 | try: 30 | answer = input() 31 | if answer.strip() == '?' and help: 32 | printer.msg(f'{help}\n') 33 | printer.msg(prompt, 'magenta') 34 | continue 35 | output = validator_func(answer) 36 | return output 37 | except ValidationError as e: 38 | printer.msg(e.message, 'red') 39 | printer.msg(prompt, 'magenta') 40 | 41 | 42 | def color_validator(input_str): 43 | """ 44 | A filter allowing only the particular colors used by the Google Calendar 45 | API 46 | 47 | Raises ValidationError otherwise. 48 | """ 49 | try: 50 | assert input_str in VALID_OVERRIDE_COLORS + [''] 51 | return input_str 52 | except AssertionError: 53 | raise ValidationError( 54 | 'Expected colors are: ' + 55 | ', '.join(color for color in VALID_OVERRIDE_COLORS) + 56 | '. (Ctrl-C to exit)\n') 57 | 58 | 59 | def str_to_int_validator(input_str): 60 | """ 61 | A filter allowing any string which can be 62 | converted to an int. 63 | Raises ValidationError otherwise. 64 | """ 65 | try: 66 | int(input_str) 67 | return input_str 68 | except ValueError: 69 | raise ValidationError( 70 | 'Input here must be a number. (Ctrl-C to exit)\n' 71 | ) 72 | 73 | 74 | def parsable_date_validator(input_str): 75 | """ 76 | A filter allowing any string which can be parsed 77 | by dateutil. 78 | Raises ValidationError otherwise. 79 | """ 80 | try: 81 | get_time_from_str(input_str) 82 | return input_str 83 | except ValueError: 84 | raise ValidationError( 85 | f'Expected format: {DATE_INPUT_DESCRIPTION}. ' 86 | '(Ctrl-C to exit)\n' 87 | ) 88 | 89 | 90 | def parsable_duration_validator(input_str): 91 | """ 92 | A filter allowing any duration string which can be parsed 93 | by parsedatetime. 94 | Raises ValidationError otherwise. 95 | """ 96 | try: 97 | get_timedelta_from_str(input_str) 98 | return input_str 99 | except ValueError: 100 | raise ValidationError( 101 | 'Expected format: a duration (e.g. 1m, 1s, 1h3m)' 102 | '(Ctrl-C to exit)\n' 103 | ) 104 | 105 | 106 | def str_allow_empty_validator(input_str): 107 | """ 108 | A simple filter that allows any string to pass. 109 | Included for completeness and for future validation if required. 110 | """ 111 | return input_str 112 | 113 | 114 | def non_blank_str_validator(input_str): 115 | """ 116 | A simple filter allowing string len > 1 and not None 117 | Raises ValidationError otherwise. 118 | """ 119 | if input_str in [None, '']: 120 | raise ValidationError( 121 | 'Input here cannot be empty. (Ctrl-C to exit)\n' 122 | ) 123 | else: 124 | return input_str 125 | 126 | 127 | def reminder_validator(input_str): 128 | """ 129 | Allows a string that matches utils.REMINDER_REGEX. 130 | Raises ValidationError otherwise. 131 | """ 132 | match = re.match(REMINDER_REGEX, input_str) 133 | if match or input_str == '.': 134 | return input_str 135 | else: 136 | raise ValidationError('Expected format: ' 137 | '. (Ctrl-C to exit)\n') 138 | 139 | 140 | STR_NOT_EMPTY = non_blank_str_validator 141 | STR_ALLOW_EMPTY = str_allow_empty_validator 142 | STR_TO_INT = str_to_int_validator 143 | PARSABLE_DATE = parsable_date_validator 144 | PARSABLE_DURATION = parsable_duration_validator 145 | VALID_COLORS = color_validator 146 | REMINDER = reminder_validator 147 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=64", "setuptools-scm>=8"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "gcalcli" 7 | dynamic = ["version"] 8 | requires-python = ">= 3.10" 9 | readme = "README.md" 10 | license = { text = "MIT" } 11 | authors = [ 12 | { name = "Eric Davis" }, 13 | { name = "Brian Hartvigsen" }, 14 | { name = "Joshua Crowgey" }, 15 | ] 16 | maintainers = [ 17 | { name = "David Barnett" }, 18 | { name = "Martin Dengler" }, 19 | ] 20 | description = "Google Calendar Command Line Interface" 21 | classifiers = [ 22 | "Development Status :: 5 - Production/Stable", 23 | "Environment :: Console", 24 | "Intended Audience :: End Users/Desktop", 25 | "License :: OSI Approved :: MIT License", 26 | "Programming Language :: Python :: 3", 27 | ] 28 | dependencies = [ 29 | "argcomplete", 30 | "babel", 31 | "google-api-python-client>=1.4", 32 | "google_auth_oauthlib", 33 | "httplib2", 34 | "parsedatetime", 35 | "platformdirs", 36 | "pydantic", 37 | "python-dateutil", 38 | "tomli; python_version < '3.11'", 39 | "truststore", 40 | ] 41 | 42 | [project.urls] 43 | Repository = "https://github.com/insanum/gcalcli" 44 | Issues = "https://github.com/insanum/gcalcli/issues" 45 | Changelog = "https://github.com/insanum/gcalcli/blob/HEAD/ChangeLog" 46 | 47 | [project.optional-dependencies] 48 | dev = [ 49 | "google-api-python-client-stubs", 50 | "types-python-dateutil", 51 | "types-requests", 52 | "types-toml; python_version < '3.11'", 53 | "types-vobject", 54 | ] 55 | vobject = ["vobject"] 56 | 57 | [tool.setuptools] 58 | packages = ["gcalcli"] 59 | 60 | [tool.setuptools.data-files] 61 | "share/man/man1" = ["docs/man1/gcalcli.1"] 62 | 63 | [tool.setuptools_scm] 64 | version_file = "gcalcli/_version.py" 65 | 66 | [project.scripts] 67 | gcalcli = "gcalcli.cli:main" 68 | 69 | [tool.ruff] 70 | line-length = 80 71 | extend-exclude = ["tests/cli/"] 72 | 73 | [tool.ruff.lint] 74 | # Enable Errors, Warnings, Flakes 75 | select = ["E", "W", "F"] 76 | 77 | [tool.ruff.format] 78 | # Permit mixed quote style, project currently uses a mix of both. 79 | quote-style = "preserve" 80 | 81 | [tool.ruff.lint.extend-per-file-ignores] 82 | "*.pyi" = ["E501"] 83 | 84 | [tool.mypy] 85 | mypy_path = "gcalcli:stubs:tests" 86 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /stubs/google/auth/credentials.pyi: -------------------------------------------------------------------------------- 1 | import abc 2 | from _typeshed import Incomplete 3 | 4 | class Credentials(metaclass=abc.ABCMeta): 5 | token: Incomplete 6 | expiry: Incomplete 7 | def __init__(self) -> None: ... 8 | @property 9 | def expired(self): ... 10 | @property 11 | def valid(self): ... 12 | @abc.abstractmethod 13 | def refresh(self, request): ... 14 | def apply(self, headers, token: Incomplete | None = None) -> None: ... 15 | def before_request(self, request, method, url, headers) -> None: ... 16 | 17 | class AnonymousCredentials(Credentials): 18 | @property 19 | def expired(self): ... 20 | @property 21 | def valid(self): ... 22 | def refresh(self, request) -> None: ... 23 | def apply(self, headers, token: Incomplete | None = None) -> None: ... 24 | def before_request(self, request, method, url, headers) -> None: ... 25 | 26 | class ReadOnlyScoped(metaclass=abc.ABCMeta): 27 | def __init__(self) -> None: ... 28 | @property 29 | def scopes(self): ... 30 | @property 31 | @abc.abstractmethod 32 | def requires_scopes(self): ... 33 | def has_scopes(self, scopes): ... 34 | 35 | class Scoped(ReadOnlyScoped, metaclass=abc.ABCMeta): 36 | @abc.abstractmethod 37 | def with_scopes(self, scopes): ... 38 | 39 | def with_scopes_if_required(credentials, scopes): ... 40 | 41 | class Signing(metaclass=abc.ABCMeta): 42 | @abc.abstractmethod 43 | def sign_bytes(self, message): ... 44 | @property 45 | @abc.abstractmethod 46 | def signer_email(self): ... 47 | @property 48 | @abc.abstractmethod 49 | def signer(self): ... 50 | -------------------------------------------------------------------------------- /stubs/google/auth/exceptions.pyi: -------------------------------------------------------------------------------- 1 | class GoogleAuthError(Exception): ... 2 | class TransportError(GoogleAuthError): ... 3 | class RefreshError(GoogleAuthError): ... 4 | class DefaultCredentialsError(GoogleAuthError): ... 5 | -------------------------------------------------------------------------------- /stubs/google/auth/transport/__init__.pyi: -------------------------------------------------------------------------------- 1 | import abc 2 | from _typeshed import Incomplete 3 | 4 | DEFAULT_REFRESH_STATUS_CODES: Incomplete 5 | DEFAULT_MAX_REFRESH_ATTEMPTS: int 6 | 7 | class Response(metaclass=abc.ABCMeta): 8 | @property 9 | @abc.abstractmethod 10 | def status(self): ... 11 | @property 12 | @abc.abstractmethod 13 | def headers(self): ... 14 | @property 15 | @abc.abstractmethod 16 | def data(self): ... 17 | 18 | class Request(metaclass=abc.ABCMeta): 19 | @abc.abstractmethod 20 | def __call__(self, url, method: str = 'GET', body: Incomplete | None = None, headers: Incomplete | None = None, timeout: Incomplete | None = None, **kwargs): ... 21 | -------------------------------------------------------------------------------- /stubs/google/auth/transport/requests.pyi: -------------------------------------------------------------------------------- 1 | import requests 2 | from _typeshed import Incomplete 3 | from google.auth import exceptions as exceptions, transport as transport 4 | 5 | class _Response(transport.Response): 6 | def __init__(self, response) -> None: ... 7 | @property 8 | def status(self): ... 9 | @property 10 | def headers(self): ... 11 | @property 12 | def data(self): ... 13 | 14 | class Request(transport.Request): 15 | session: Incomplete 16 | def __init__(self, session: Incomplete | None = None) -> None: ... 17 | def __call__(self, url, method: str = 'GET', body: Incomplete | None = None, headers: Incomplete | None = None, timeout: Incomplete | None = None, **kwargs): ... 18 | 19 | class AuthorizedSession(requests.Session): 20 | credentials: Incomplete 21 | def __init__(self, credentials, refresh_status_codes=..., max_refresh_attempts=..., refresh_timeout: Incomplete | None = None, **kwargs) -> None: ... 22 | -------------------------------------------------------------------------------- /stubs/google/oauth2/credentials.pyi: -------------------------------------------------------------------------------- 1 | from _typeshed import Incomplete 2 | from google.auth import credentials as credentials, exceptions as exceptions 3 | 4 | class Credentials(credentials.ReadOnlyScoped, credentials.Credentials): 5 | token: Incomplete 6 | def __init__(self, token, refresh_token: Incomplete | None = None, id_token: Incomplete | None = None, token_uri: Incomplete | None = None, client_id: Incomplete | None = None, client_secret: Incomplete | None = None, scopes: Incomplete | None = None) -> None: ... 7 | @property 8 | def refresh_token(self): ... 9 | @property 10 | def token_uri(self): ... 11 | @property 12 | def id_token(self): ... 13 | @property 14 | def client_id(self): ... 15 | @property 16 | def client_secret(self): ... 17 | @property 18 | def requires_scopes(self): ... 19 | expiry: Incomplete 20 | def refresh(self, request) -> None: ... 21 | @classmethod 22 | def from_authorized_user_info(cls, info, scopes: Incomplete | None = None): ... 23 | @classmethod 24 | def from_authorized_user_file(cls, filename, scopes: Incomplete | None = None): ... 25 | -------------------------------------------------------------------------------- /stubs/google_auth_oauthlib/flow.pyi: -------------------------------------------------------------------------------- 1 | import wsgiref.simple_server 2 | from google.oauth2.credentials import Credentials 3 | from _typeshed import Incomplete 4 | 5 | class Flow: 6 | client_type: Incomplete 7 | client_config: Incomplete 8 | oauth2session: Incomplete 9 | code_verifier: Incomplete 10 | autogenerate_code_verifier: Incomplete 11 | def __init__(self, oauth2session, client_type, client_config, redirect_uri: Incomplete | None = None, code_verifier: Incomplete | None = None, autogenerate_code_verifier: bool = True) -> None: ... 12 | @classmethod 13 | def from_client_config(cls, client_config, scopes, **kwargs): ... 14 | @classmethod 15 | def from_client_secrets_file(cls, client_secrets_file, scopes, **kwargs): ... 16 | @property 17 | def redirect_uri(self): ... 18 | @redirect_uri.setter 19 | def redirect_uri(self, value) -> None: ... 20 | def authorization_url(self, **kwargs): ... 21 | def fetch_token(self, **kwargs): ... 22 | @property 23 | def credentials(self): ... 24 | def authorized_session(self): ... 25 | 26 | class InstalledAppFlow(Flow): 27 | redirect_uri: Incomplete 28 | def run_local_server(self, host: str = 'localhost', bind_addr: Incomplete | None = None, port: int = 8080, authorization_prompt_message=..., success_message=..., open_browser: bool = True, redirect_uri_trailing_slash: bool = True, timeout_seconds: Incomplete | None = None, token_audience: Incomplete | None = None, browser: Incomplete | None = None, **kwargs) -> Credentials: ... 29 | 30 | class _WSGIRequestHandler(wsgiref.simple_server.WSGIRequestHandler): 31 | def log_message(self, format, *args) -> None: ... 32 | 33 | class _RedirectWSGIApp: 34 | last_request_uri: Incomplete 35 | def __init__(self, success_message) -> None: ... 36 | def __call__(self, environ, start_response): ... 37 | -------------------------------------------------------------------------------- /stubs/parsedatetime/parsedatetime/__init__.pyi: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import time 4 | from _typeshed import Incomplete 5 | from collections.abc import Generator 6 | from typing import Tuple 7 | 8 | __version__: str 9 | __url__: str 10 | __download_url__: str 11 | __description__: str 12 | 13 | class NullHandler(logging.Handler): 14 | def emit(self, record) -> None: ... 15 | 16 | log: Incomplete 17 | debug: bool 18 | pdtLocales: Incomplete 19 | VERSION_FLAG_STYLE: int 20 | VERSION_CONTEXT_STYLE: int 21 | 22 | class Calendar: 23 | ptc: Incomplete 24 | version: Incomplete 25 | def __init__(self, constants: Incomplete | None = None, version=...) -> None: ... 26 | def context(self) -> Generator[Incomplete, None, None]: ... 27 | @property 28 | def currentContext(self): ... 29 | def parseDate(self, dateString, sourceTime: Incomplete | None = None): ... 30 | def parseDateText(self, dateString, sourceTime: Incomplete | None = None): ... 31 | def evalRanges(self, datetimeString, sourceTime: Incomplete | None = None): ... 32 | def parseDT(self, datetimeString, sourceTime: Incomplete | None = None, tzinfo: Incomplete | None = None, version: Incomplete | None = None) -> Tuple[datetime.datetime, Incomplete]: ... 33 | def parse(self, datetimeString, sourceTime: Incomplete | None = None, version: Incomplete | None = None) -> Tuple[time.struct_time, Incomplete]: ... 34 | def inc(self, source, month: Incomplete | None = None, year: Incomplete | None = None): ... 35 | def nlp(self, inputString, sourceTime: Incomplete | None = None, version: Incomplete | None = None): ... 36 | 37 | class Constants: 38 | localeID: Incomplete 39 | fallbackLocales: Incomplete 40 | locale: Incomplete 41 | usePyICU: Incomplete 42 | Second: int 43 | Minute: int 44 | Hour: int 45 | Day: int 46 | Week: int 47 | Month: int 48 | Year: int 49 | rangeSep: str 50 | BirthdayEpoch: int 51 | StartTimeFromSourceTime: bool 52 | StartHour: int 53 | YearParseStyle: int 54 | DOWParseStyle: int 55 | CurrentDOWParseStyle: bool 56 | RE_DATE4: Incomplete 57 | RE_DATE3: Incomplete 58 | RE_MONTH: Incomplete 59 | RE_WEEKDAY: Incomplete 60 | RE_NUMBER: Incomplete 61 | RE_SPECIAL: Incomplete 62 | RE_UNITS_ONLY: Incomplete 63 | RE_UNITS: Incomplete 64 | RE_QUNITS: Incomplete 65 | RE_MODIFIER: Incomplete 66 | RE_TIMEHMS: Incomplete 67 | RE_TIMEHMS2: Incomplete 68 | RE_NLP_PREFIX: str 69 | RE_DATE: Incomplete 70 | RE_DATE2: Incomplete 71 | RE_DAY: Incomplete 72 | RE_DAY2: Incomplete 73 | RE_TIME: Incomplete 74 | RE_REMAINING: str 75 | RE_RTIMEHMS: Incomplete 76 | RE_RTIMEHMS2: Incomplete 77 | RE_RDATE: Incomplete 78 | RE_RDATE3: Incomplete 79 | DATERNG1: Incomplete 80 | DATERNG2: Incomplete 81 | DATERNG3: Incomplete 82 | TIMERNG1: Incomplete 83 | TIMERNG2: Incomplete 84 | TIMERNG3: Incomplete 85 | TIMERNG4: Incomplete 86 | re_option: Incomplete 87 | cre_source: Incomplete 88 | cre_keys: Incomplete 89 | def __init__(self, localeID: Incomplete | None = None, usePyICU: bool = True, fallbackLocales=['en_US']) -> None: ... 90 | def __getattr__(self, name): ... 91 | def daysInMonth(self, month, year): ... 92 | def getSource(self, sourceKey, sourceTime: Incomplete | None = None): ... 93 | -------------------------------------------------------------------------------- /stubs/vobject/__init__.pyi: -------------------------------------------------------------------------------- 1 | # Fork of Typeshed's stubs to work around explicit export issue 2 | # https://github.com/py-vobject/vobject/issues/53. 3 | from .base import Component, readComponents as readComponents 4 | 5 | def iCalendar() -> Component: ... 6 | def vCard() -> Component: ... 7 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Tests for gcalcli 2 | 3 | This directory contains unit tests and functional tests for gcalcli. To run them all, make sure 4 | you've installed [tox](https://tox.wiki/) and a supported python version, then in the repository root dir run 5 | 6 | ```shell 7 | git submodule update --init 8 | tox 9 | ``` 10 | 11 | Or run individual configurations like 12 | 13 | ```shell 14 | tox -e py38,cli 15 | ``` 16 | 17 | The supported tox testing envs are listed and configured in ../tox.ini. 18 | 19 | They're also configured to run on GitHub pull requests for various platforms and python versions 20 | (config: ../.github/workflows/tests.yml). 21 | 22 | ## Linters and type checking 23 | 24 | Tox will also run [Ruff](https://docs.astral.sh/ruff/) linters and [mypy](https://mypy-lang.org/) 25 | type checking on the code (configured in the root dir, not under tests/). 26 | 27 | If a weird Ruff check is giving you grief, you might need to 28 | [ignore it](https://docs.astral.sh/ruff/settings/#lint_extend-per-file-ignores) in the pyproject.toml. 29 | 30 | The type checking can also give you grief, particularly for library types it can't resolve. See 31 | https://mypy.readthedocs.io/en/stable/common_issues.html for troubleshooting info. Some issues can 32 | be resolved by using their stubgen tool to generate more type stubs under stubs/, or tweaking the 33 | existing .pyi files there to better reflect reality. 34 | 35 | ## Unit tests 36 | 37 | The python test files under tests/ are unit tests run via [pytest](https://pytest.org/). If you 38 | hit failures, you can start a debugger with the --pdb flag to troubleshoot (probably also 39 | specifying an individual test env and test to debug). Example: 40 | 41 | ```shell 42 | tox -e py312 -- tests/test_gcalcli.py::test_import --pdb 43 | ``` 44 | 45 | ## Functional cli tests 46 | 47 | Under tests/cli/ there are also high-level tests running the cli in a shell using the 48 | [Bats](https://bats-core.readthedocs.io/) tool. They have a .bats extension. These can be run 49 | individually with `tox -e cli`. 50 | 51 | NOTE: They'll fail if you haven't initialized the repo submodules for Bats yet, so if you hit 52 | errors for missing test runner files, make sure you've run `git submodule update --init` in the 53 | repo. 54 | 55 | Some tests may fail on `assert_snapshot` calls from the 56 | [bats-snapshot](https://github.com/markkong318/bats-snapshot) helper, in which case you can easily 57 | update snapshots by finding and deleting the corresponding .snap file in \__snapshots__/, rerunning 58 | the cli tests, and then reviewing the updated snapshot file to make sure the diff is expected. 59 | -------------------------------------------------------------------------------- /tests/cli/.ignore: -------------------------------------------------------------------------------- 1 | bats/** 2 | -------------------------------------------------------------------------------- /tests/cli/__snapshot__/test-02-test_prints_correct_help.snap: -------------------------------------------------------------------------------- 1 | 0 2 | usage: gcalcli [-h] [--version] [--client-id CLIENT_ID] 3 | [--client-secret CLIENT_SECRET] [--noauth_local_server] 4 | [--config-folder CONFIG_FOLDER] [--noincluderc] 5 | [--calendar GLOBAL_CALENDARS] 6 | [--default-calendar DEFAULT_CALENDARS] 7 | [--locale LOCALE] [--refresh] [--nocache] [--conky] 8 | [--nocolor] [--lineart {fancy,unicode,ascii}] 9 | {init,list,search,edit,delete,agenda,agendaupdate,updates,conflicts,calw,calm,quick,add,import,remind,config,util} 10 | ... 11 | 12 | Google Calendar Command Line Interface 13 | 14 | configuration: 15 | gcalcli supports a few other configuration mechanisms in addition to 16 | the command-line arguments listed below. 17 | 18 | $GCALCLI_CONFIG=/some/gcalcli/config 19 | Path to user config directory or file. 20 | Note: you can place an 'oauth' file in this config directory to 21 | support using different accounts per config. 22 | 23 | /some/gcalcli/config/config.toml 24 | A toml config file where some general-purpose settings can be 25 | configured. 26 | Schema: 27 | https://raw.githubusercontent.com/insanum/gcalcli/HEAD/data/config-schema.json 28 | 29 | gcalclirc @ /some/gcalcli/config/gcalclirc 30 | A flag file listing additional command-line args to always pass, 31 | one per line. 32 | Note: Use this sparingly and prefer other configuration mechanisms 33 | where available. This flag file mechanism can be brittle 34 | (example: https://github.com/insanum/gcalcli/issues/513). 35 | 36 | positional arguments: 37 | {init,list,search,edit,delete,agenda,agendaupdate,updates,conflicts,calw,calm,quick,add,import,remind,config,util} 38 | Invoking a subcommand with --help prints 39 | subcommand usage. 40 | init initialize authentication, etc 41 | list list available calendars 42 | search search for events within an optional time 43 | period 44 | edit edit calendar events 45 | delete delete events from the calendar 46 | agenda get an agenda for a time period 47 | agendaupdate update calendar from agenda TSV file 48 | updates get updates since a datetime for a time period 49 | (defaults to through end of current month) 50 | conflicts find event conflicts 51 | calw get a week-based agenda in calendar format 52 | calm get a month agenda in calendar format 53 | quick quick-add an event to a calendar 54 | add add a detailed event to the calendar 55 | import import an ics/vcal file to a calendar 56 | remind execute command if event occurs within 57 | time 58 | config utility commands to work with configuration 59 | util low-level utility commands for introspection, 60 | dumping schemas, etc 61 | 62 | options: 63 | -h, --help show this help message and exit 64 | --version show program's version number and exit 65 | --client-id CLIENT_ID 66 | API client_id (default: None) 67 | --client-secret CLIENT_SECRET 68 | API client_secret (default: None) 69 | --noauth_local_server 70 | Provide instructions for authenticating from a 71 | remote system using port forwarding. Note: 72 | Previously this option invoked an "Out-Of- 73 | Band" variant of the auth flow, but that 74 | deprecated mechanism is no longer supported. 75 | (default: True) 76 | --config-folder CONFIG_FOLDER 77 | Optional directory used to load config files. 78 | Deprecated: prefer $GCALCLI_CONFIG. (default: 79 | /some/gcalcli/config) 80 | --noincluderc Whether to include ~/.gcalclirc. (default: 81 | True) 82 | --calendar GLOBAL_CALENDARS 83 | Which calendars to use, in the format 84 | "CalendarName" or "CalendarName#color". 85 | Supported here globally for compatibility 86 | purposes, but prefer passing to individual 87 | commands after the command name since this 88 | global version is brittle. (default: []) 89 | --default-calendar DEFAULT_CALENDARS 90 | Optional default calendar to use if no 91 | --calendar options are given (default: []) 92 | --locale LOCALE System locale (default: ) 93 | --refresh Delete and refresh cached data (default: 94 | False) 95 | --nocache Execute command without using cache (default: 96 | True) 97 | --conky Use Conky color codes (default: False) 98 | --nocolor Enable/Disable all color output (default: 99 | True) 100 | --lineart {fancy,unicode,ascii} 101 | Choose line art style for calendars: "fancy": 102 | for VTcodes, "unicode" for Unicode box drawing 103 | characters, "ascii" for old-school plusses, 104 | hyphens and pipes. (default: fancy) 105 | -------------------------------------------------------------------------------- /tests/cli/__snapshot__/test-03-test_can_run_init.snap: -------------------------------------------------------------------------------- 1 | 0 2 | Running in GCALCLI_USERLESS_MODE. Most operations will fail! 3 | Starting auth flow... 4 | NOTE: See https://github.com/insanum/gcalcli/blob/HEAD/docs/api-auth.md for help/troubleshooting. 5 | You'll be asked for a client_secret that you should have set up for yourself in Google dev console. 6 | Client Secret: Now click the link below and follow directions to authenticate. 7 | You will likely see a security warning page and need to click "Advanced" and "Go to gcalcli (unsafe)" to proceed. 8 | Skipping actual authentication (running in userless mode) 9 |  10 | -------------------------------------------------------------------------------- /tests/cli/__snapshot__/test-04-test_can_run_add.snap: -------------------------------------------------------------------------------- 1 | 1 2 | Running in GCALCLI_USERLESS_MODE. Most operations will fail! 3 | Prompting for unfilled values. 4 | Run with --noprompt to leave them unfilled without prompting. 5 | Title: Location: When (? for help): Duration (human readable): Description: Enter a valid reminder or "." to end: No available calendar to use 6 | -------------------------------------------------------------------------------- /tests/cli/run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | BATS="./tests/cli/bats/bin/bats" 4 | if [ ! -f $BATS ]; then 5 | echo "FAILED to run cli tests: Missing bats runner!" >&2 6 | echo "(Did you forget to run 'git submodule update --init'?)" >&2 7 | exit 1 8 | fi 9 | $BATS "$@" 10 | -------------------------------------------------------------------------------- /tests/cli/test.bats: -------------------------------------------------------------------------------- 1 | setup() { 2 | load 'test_helper/bats-support/load' 3 | load 'test_helper/bats-assert/load' 4 | load 'test_helper/bats-file/load' 5 | load 'test_helper/bats-snapshot/load' 6 | 7 | TEST_HOME_DIR="$(temp_make)" 8 | export HOME="$TEST_HOME_DIR" 9 | export GCALCLI_USERLESS_MODE=1 10 | } 11 | 12 | function teardown() { 13 | temp_del "$TEST_HOME_DIR" 14 | } 15 | 16 | @test "can run" { 17 | run gcalcli 18 | assert_failure 2 19 | assert_output --regexp 'usage: .*error:.*required: .*command' 20 | } 21 | 22 | @test "prints correct help" { 23 | GCALCLI_CONFIG=/some/gcalcli/config COLUMNS=72 run gcalcli -h 24 | assert_success 25 | assert_snapshot 26 | } 27 | 28 | @test "can run init" { 29 | run gcalcli init --client-id=SOME_CLIENT <<< "SOME_SECRET 30 | " 31 | assert_snapshot 32 | } 33 | 34 | @test "can run add" { 35 | run gcalcli add <<< "sometitle 36 | 37 | tomorrow 38 | 39 | 40 | ." 41 | assert_snapshot 42 | } 43 | -------------------------------------------------------------------------------- /tests/cli/test_helper/.ignore: -------------------------------------------------------------------------------- 1 | ** 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from datetime import datetime, timedelta 4 | from types import SimpleNamespace 5 | 6 | import google.oauth2.reauth 7 | import pytest 8 | from dateutil.tz import tzlocal 9 | from googleapiclient.discovery import HttpMock, build 10 | 11 | from gcalcli.argparsers import (get_cal_query_parser, get_color_parser, 12 | get_output_parser) 13 | from gcalcli.gcal import GoogleCalendarInterface 14 | from gcalcli.printer import Printer 15 | 16 | TEST_DATA_DIR = os.path.dirname(os.path.abspath(__file__)) + '/data' 17 | 18 | mock_event = [{'colorId': "10", 19 | 'created': '2018-12-31T09:20:32.000Z', 20 | 'creator': {'email': 'matthew.lemon@gmail.com'}, 21 | 'e': datetime(2019, 1, 8, 15, 15, tzinfo=tzlocal()), 22 | 'end': {'dateTime': '2019-01-08T15:15:00Z'}, 23 | 'etag': '"3092496064420000"', 24 | 'gcalcli_cal': {'accessRole': 'owner', 25 | 'backgroundColor': '#4986e7', 26 | 'colorId': '16', 27 | 'conferenceProperties': { 28 | 'allowedConferenceSolutionTypes': 29 | ['eventHangout'] 30 | }, 31 | 'defaultReminders': [], 32 | 'etag': '"153176133553000"', 33 | 'foregroundColor': '#000000', 34 | 'id': '12pp3nqo@group.calendar.google.com', 35 | 'kind': 'calendar#calendarListEntry', 36 | 'selected': True, 37 | 'summary': 'Test Calendar', 38 | 'timeZone': 'Europe/London'}, 39 | 'htmlLink': '', 40 | 'iCalUID': '31376E6-8B63-416C-B73A-74D10F51F', 41 | 'id': '_6coj0c9o88r3b9a26spk2b9n6sojed2464o4cd9h8o', 42 | 'kind': 'calendar#event', 43 | 'organizer': { 44 | 'displayName': 'Test Calendar', 45 | 'email': 'tst@group.google.com', 46 | 'self': True}, 47 | 'reminders': {'useDefault': True}, 48 | 's': datetime(2019, 1, 8, 14, 15, tzinfo=tzlocal()), 49 | 'sequence': 0, 50 | 'start': {'dateTime': '2019-01-08T14:15:00Z'}, 51 | 'status': 'confirmed', 52 | 'summary': 'Test Event', 53 | 'updated': '2018-12-31T09:20:32.210Z'}] 54 | 55 | 56 | @pytest.fixture 57 | def default_options(): 58 | opts = vars(get_color_parser().parse_args([])) 59 | opts.update(vars(get_cal_query_parser().parse_args([]))) 60 | opts.update(vars(get_output_parser().parse_args([]))) 61 | return opts 62 | 63 | 64 | @pytest.fixture 65 | def PatchedGCalIForEvents(PatchedGCalI, monkeypatch): 66 | def mocked_search_for_events(self, start, end, search_text): 67 | return mock_event 68 | 69 | monkeypatch.setattr( 70 | GoogleCalendarInterface, '_search_for_events', mocked_search_for_events 71 | ) 72 | 73 | return PatchedGCalI 74 | 75 | 76 | @pytest.fixture 77 | def PatchedGCalI(gcali_patches): 78 | gcali_patches.stub_out_cal_service() 79 | return gcali_patches.GCalI 80 | 81 | 82 | @pytest.fixture 83 | def gcali_patches(monkeypatch): 84 | def mocked_cal_service(self): 85 | http = HttpMock( 86 | TEST_DATA_DIR + '/cal_service_discovery.json', {'status': '200'} 87 | ) 88 | if not self.cal_service: 89 | self.cal_service = build( 90 | serviceName='calendar', version='v3', http=http 91 | ) 92 | return self.cal_service 93 | 94 | def mocked_calendar_list(self): 95 | http = HttpMock(TEST_DATA_DIR + '/cal_list.json', {'status': '200'}) 96 | request = self.get_cal_service().calendarList().list() 97 | cal_list = request.execute(http=http) 98 | self.all_cals = [cal for cal in cal_list['items']] 99 | if not self.cal_service: 100 | self.cal_service = build( 101 | serviceName='calendar', version='v3', http=http 102 | ) 103 | return self.cal_service 104 | 105 | def mocked_msg(self, msg, colorname='default', file=sys.stdout): 106 | # ignores file and always writes to stdout 107 | if self.use_color: 108 | msg = self.colors[colorname] + msg + self.colors['default'] 109 | sys.stdout.write(msg) 110 | 111 | monkeypatch.setattr( 112 | GoogleCalendarInterface, '_get_cached', mocked_calendar_list 113 | ) 114 | monkeypatch.setattr(Printer, 'msg', mocked_msg) 115 | 116 | def data_file_path_stub(self, name): 117 | stubbed_path = getattr(self, '_stubbed_data_path', None) 118 | if stubbed_path is None: 119 | return None 120 | return stubbed_path.joinpath(name) 121 | monkeypatch.setattr( 122 | GoogleCalendarInterface, 'data_file_path', data_file_path_stub) 123 | 124 | orig_init = GoogleCalendarInterface.__init__ 125 | def modified_init(self, *args, data_path=None, **kwargs): 126 | self._stubbed_data_path = data_path 127 | kwargs.setdefault('ignore_calendars', []) 128 | return orig_init(self, *args, **kwargs, use_cache=False) 129 | monkeypatch.setattr(GoogleCalendarInterface, '__init__', modified_init) 130 | 131 | return SimpleNamespace( 132 | GCalI=GoogleCalendarInterface, 133 | stub_out_cal_service=lambda: monkeypatch.setattr( 134 | GoogleCalendarInterface, 'get_cal_service', mocked_cal_service 135 | ), 136 | ) 137 | 138 | 139 | @pytest.fixture 140 | def patched_google_reauth(monkeypatch): 141 | def mocked_refresh_grant(*args, **kw): 142 | expiry = datetime.now() + timedelta(minutes=60) 143 | grant_response = {} 144 | return ( 145 | 'some_access_token', 146 | 'some_refresh_token', 147 | expiry, 148 | grant_response, 149 | 'some_rapt_token', 150 | ) 151 | 152 | monkeypatch.setattr( 153 | google.oauth2.reauth, 'refresh_grant', mocked_refresh_grant 154 | ) 155 | return monkeypatch 156 | -------------------------------------------------------------------------------- /tests/data/cal_list.json: -------------------------------------------------------------------------------- 1 | { 2 | "nextSyncToken": "somebase64text==", 3 | "items": [ 4 | { 5 | "summary": "jcrowgey@uw.edu", 6 | "accessRole": "owner", 7 | "etag": "\"etag0\"", 8 | "location": "Seattle", 9 | "defaultReminders": [], 10 | "colorId": "15", 11 | "timeZone": "America/Los_Angeles", 12 | "foregroundColor": "#000000", 13 | "id": "jcrowgey@uw.edu", 14 | "selected": true, 15 | "backgroundColor": "#9fc6e7", 16 | "conferenceProperties": { 17 | "allowedConferenceSolutionTypes": [ 18 | "eventNamedHangout" 19 | ] 20 | }, 21 | "kind": "calendar#calendarListEntry" 22 | }, 23 | { 24 | "summary": "Google Code Jam", 25 | "accessRole": "reader", 26 | "etag": "\"1522646459661000\"", 27 | "description": "Public calendar of important dates and events for Google Code Jam competitions.", 28 | "colorId": "18", 29 | "timeZone": "UTC", 30 | "foregroundColor": "#000000", 31 | "defaultReminders": [], 32 | "id": "google.com_jqv7qt9iifsaj94cuknckrabd8@group.calendar.google.com", 33 | "selected": true, 34 | "backgroundColor": "#b99aff", 35 | "conferenceProperties": { 36 | "allowedConferenceSolutionTypes": [ 37 | "eventNamedHangout" 38 | ] 39 | }, 40 | "kind": "calendar#calendarListEntry" 41 | }, 42 | { 43 | "notificationSettings": { 44 | "notifications": [ 45 | { 46 | "method": "email", 47 | "type": "eventCreation" 48 | }, 49 | { 50 | "method": "email", 51 | "type": "eventChange" 52 | }, 53 | { 54 | "method": "email", 55 | "type": "eventCancellation" 56 | }, 57 | { 58 | "method": "email", 59 | "type": "eventResponse" 60 | } 61 | ] 62 | }, 63 | "summary": "joshuacrowgey@gmail.com", 64 | "accessRole": "owner", 65 | "etag": "\"etag1\"", 66 | "defaultReminders": [ 67 | { 68 | "method": "email", 69 | "minutes": 10 70 | }, 71 | { 72 | "method": "popup", 73 | "minutes": 30 74 | } 75 | ], 76 | "colorId": "2", 77 | "timeZone": "America/Los_Angeles", 78 | "foregroundColor": "#000000", 79 | "id": "joshuacrowgey@gmail.com", 80 | "primary": true, 81 | "selected": true, 82 | "backgroundColor": "#d06b64", 83 | "conferenceProperties": { 84 | "allowedConferenceSolutionTypes": [ 85 | "eventHangout" 86 | ] 87 | }, 88 | "kind": "calendar#calendarListEntry" 89 | }, 90 | { 91 | "summary": "Contacts", 92 | "accessRole": "reader", 93 | "etag": "\"etag2\"", 94 | "defaultReminders": [], 95 | "colorId": "13", 96 | "timeZone": "America/Los_Angeles", 97 | "foregroundColor": "#000000", 98 | "id": "#contacts@group.v.calendar.google.com", 99 | "selected": true, 100 | "backgroundColor": "#92e1c0", 101 | "conferenceProperties": { 102 | "allowedConferenceSolutionTypes": [ 103 | "eventHangout" 104 | ] 105 | }, 106 | "kind": "calendar#calendarListEntry" 107 | }, 108 | { 109 | "summary": "Holidays in United States", 110 | "accessRole": "reader", 111 | "etag": "\"0\"", 112 | "defaultReminders": [], 113 | "colorId": "15", 114 | "timeZone": "America/Los_Angeles", 115 | "foregroundColor": "#000000", 116 | "id": "en.usa#holiday@group.v.calendar.google.com", 117 | "selected": true, 118 | "backgroundColor": "#9fc6e7", 119 | "conferenceProperties": { 120 | "allowedConferenceSolutionTypes": [ 121 | "eventHangout" 122 | ] 123 | }, 124 | "kind": "calendar#calendarListEntry" 125 | }, 126 | { 127 | "summary": "Sunrise/Sunset: Seattle", 128 | "accessRole": "reader", 129 | "etag": "\"0\"", 130 | "defaultReminders": [], 131 | "colorId": "6", 132 | "timeZone": "America/Los_Angeles", 133 | "foregroundColor": "#000000", 134 | "id": "i_71.23.40.78#sunrise@group.v.calendar.google.com", 135 | "selected": true, 136 | "backgroundColor": "#ffad46", 137 | "conferenceProperties": { 138 | "allowedConferenceSolutionTypes": [ 139 | "eventHangout" 140 | ] 141 | }, 142 | "kind": "calendar#calendarListEntry" 143 | } 144 | ], 145 | "etag": "\"etag3\"", 146 | "kind": "calendar#calendarList" 147 | } 148 | -------------------------------------------------------------------------------- /tests/data/legacy_oauth_creds.json: -------------------------------------------------------------------------------- 1 | {"access_token": "d3adb33f", "client_id": "someclient.apps.googleusercontent.com", "client_secret": "SECRET-secret-SECRET", "refresh_token": "SOME-REFRESH-TOKEN", "token_expiry": "2099-01-01T00:00:00Z", "token_uri": "https://oauth2.googleapis.com/token", "user_agent": "gcalcli/v4.3.0", "revoke_uri": "https://oauth2.googleapis.com/revoke", "id_token": null, "id_token_jwt": null, "token_response": {"access_token": "some.ACCESS-tOKeN", "expires_in": 3599, "refresh_token": "SOME-REFRESH-TOKEN", "scope": "https://www.googleapis.com/auth/calendar", "token_type": "Bearer"}, "scopes": ["https://www.googleapis.com/auth/calendar"], "token_info_uri": "https://oauth2.googleapis.com/tokeninfo", "invalid": false, "_class": "OAuth2Credentials", "_module": "oauth2client.client"} -------------------------------------------------------------------------------- /tests/data/vv.txt: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | METHOD:REQUEST 3 | PRODID:Microsoft Exchange Server 2010 4 | VERSION:2.0 5 | BEGIN:VTIMEZONE 6 | TZID:Israel Standard Time 7 | BEGIN:STANDARD 8 | DTSTART:16010101T020000 9 | TZOFFSETFROM:+0300 10 | TZOFFSETTO:+0200 11 | RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10 12 | END:STANDARD 13 | BEGIN:DAYLIGHT 14 | DTSTART:16010101T020000 15 | TZOFFSETFROM:+0200 16 | TZOFFSETTO:+0300 17 | RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=4FR;BYMONTH=3 18 | END:DAYLIGHT 19 | END:VTIMEZONE 20 | BEGIN:VEVENT 21 | ORGANIZER;CN=שמואל גרובר;SENT-BY="MAILTO:ganon@bgu.ac.il":MAILTO:sh 22 | muel@bgu.ac.il 23 | ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN=משה ק 24 | מנסקי:MAILTO:kamenskm@bgu.ac.il 25 | ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN=ערן ל 26 | קס:MAILTO:eran@bgu.ac.il 27 | DESCRIPTION;LANGUAGE=he-IL:משה 0558956195\n 28 | UID:040000008200E00074C5B7101A82E0080000000010FAD95E7008D401000000000000000 29 | 01000000053FEBBF5E4391447865D627F95C692CE 30 | SUMMARY;LANGUAGE=he-IL:גישה לשרתי ssh 31 | DTSTART;TZID=Israel Standard Time:20180701T130000 32 | DTEND;TZID=Israel Standard Time:20180701T133000 33 | CLASS:PUBLIC 34 | PRIORITY:5 35 | DTSTAMP:20180701T070519Z 36 | TRANSP:OPAQUE 37 | STATUS:CONFIRMED 38 | SEQUENCE:3 39 | LOCATION;LANGUAGE=he-IL:שמוליק 40 | X-MICROSOFT-CDO-APPT-SEQUENCE:3 41 | X-MICROSOFT-CDO-OWNERAPPTID:-279681054 42 | X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE 43 | X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY 44 | X-MICROSOFT-CDO-ALLDAYEVENT:FALSE 45 | X-MICROSOFT-CDO-IMPORTANCE:1 46 | X-MICROSOFT-CDO-INSTTYPE:0 47 | X-MICROSOFT-DISALLOW-COUNTER:FALSE 48 | BEGIN:VALARM 49 | DESCRIPTION:REMINDER 50 | TRIGGER;RELATED=START:-PT15M 51 | ACTION:DISPLAY 52 | END:VALARM 53 | END:VEVENT 54 | END:VCALENDAR 55 | -------------------------------------------------------------------------------- /tests/test_argparsers.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | import shlex 3 | 4 | import pytest 5 | 6 | from gcalcli import argparsers 7 | 8 | 9 | def test_get_argparser(): 10 | """Just asserts no errors have been introduced""" 11 | argparser = argparsers.get_argument_parser() 12 | assert argparser 13 | 14 | 15 | def test_reminder_parser(): 16 | remind_parser = argparsers.get_remind_parser() 17 | argv = shlex.split('--reminder invalid reminder') 18 | with pytest.raises(SystemExit): 19 | remind_parser.parse_args(argv) 20 | 21 | argv = shlex.split('--reminder "5m sms"') 22 | assert len(remind_parser.parse_args(argv).reminders) == 1 23 | 24 | 25 | def test_output_parser(monkeypatch): 26 | def sub_terminal_size(columns): 27 | ts = namedtuple('terminal_size', ['lines', 'columns']) 28 | 29 | def fake_get_terminal_size(): 30 | return ts(123, columns) 31 | 32 | return fake_get_terminal_size 33 | 34 | output_parser = argparsers.get_output_parser() 35 | argv = shlex.split('-w 29') 36 | with pytest.raises(SystemExit): 37 | output_parser.parse_args(argv) 38 | 39 | argv = shlex.split('-w 30') 40 | assert output_parser.parse_args(argv).width == 30 41 | 42 | argv = shlex.split('') 43 | monkeypatch.setattr(argparsers, 'get_terminal_size', sub_terminal_size(70)) 44 | output_parser = argparsers.get_output_parser() 45 | assert output_parser.parse_args(argv).width == 70 46 | 47 | argv = shlex.split('') 48 | monkeypatch.setattr(argparsers, 'get_terminal_size', 49 | sub_terminal_size(100)) 50 | output_parser = argparsers.get_output_parser() 51 | assert output_parser.parse_args(argv).width == 100 52 | 53 | 54 | def test_search_parser(): 55 | search_parser = argparsers.get_search_parser() 56 | with pytest.raises(SystemExit): 57 | search_parser.parse_args([]) 58 | 59 | 60 | def test_updates_parser(): 61 | updates_parser = argparsers.get_updates_parser() 62 | 63 | argv = shlex.split('2019-07-18 2019-08-01 2019-09-01') 64 | parsed_updates = updates_parser.parse_args(argv) 65 | assert parsed_updates.since 66 | assert parsed_updates.start 67 | assert parsed_updates.end 68 | 69 | 70 | def test_conflicts_parser(): 71 | updates_parser = argparsers.get_conflicts_parser() 72 | 73 | argv = shlex.split('search 2019-08-01 2019-09-01') 74 | parsed_conflicts = updates_parser.parse_args(argv) 75 | assert parsed_conflicts.text 76 | assert parsed_conflicts.start 77 | assert parsed_conflicts.end 78 | 79 | 80 | def test_details_parser(): 81 | details_parser = argparsers.get_details_parser() 82 | 83 | argv = shlex.split('--details attendees --details url ' 84 | '--details location --details end') 85 | parsed_details = details_parser.parse_args(argv).details 86 | assert parsed_details['attendees'] 87 | assert parsed_details['location'] 88 | assert parsed_details['url'] 89 | assert parsed_details['end'] 90 | 91 | argv = shlex.split('--details all') 92 | parsed_details = details_parser.parse_args(argv).details 93 | assert all(parsed_details[d] for d in argparsers.DETAILS) 94 | 95 | 96 | def test_handle_unparsed(): 97 | # minimal test showing that we can parse a global option after the 98 | # subcommand (in some cases) 99 | parser = argparsers.get_argument_parser() 100 | argv = shlex.split('delete --calendar=test "search text"') 101 | parsed, unparsed = parser.parse_known_args(argv) 102 | parsed = argparsers.handle_unparsed(unparsed, parsed) 103 | assert parsed.calendars == ['test'] 104 | -------------------------------------------------------------------------------- /tests/test_auth.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import shutil 3 | 4 | try: 5 | import cPickle as pickle # type: ignore 6 | except Exception: 7 | import pickle 8 | 9 | import googleapiclient.discovery 10 | 11 | TEST_DATA_DIR = pathlib.Path(__file__).parent / 'data' 12 | 13 | 14 | def test_legacy_certs(tmpdir, gcali_patches, patched_google_reauth): 15 | tmpdir = pathlib.Path(tmpdir) 16 | oauth_filepath = tmpdir / 'oauth' 17 | shutil.copy(TEST_DATA_DIR / 'legacy_oauth_creds.json', oauth_filepath) 18 | gcal = gcali_patches.GCalI(data_path=tmpdir, refresh_cache=False) 19 | assert isinstance( 20 | gcal.get_cal_service(), googleapiclient.discovery.Resource 21 | ) 22 | with open(oauth_filepath, 'rb') as gcalcli_oauth: 23 | try: 24 | pickle.load(gcalcli_oauth) 25 | except Exception as e: 26 | raise AssertionError( 27 | f"Couldn't load oauth file as updated pickle format: {e}" 28 | ) 29 | -------------------------------------------------------------------------------- /tests/test_conflicts.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from dateutil.tz import tzlocal 4 | 5 | from gcalcli.conflicts import ShowConflicts 6 | 7 | minimal_event = { 8 | 'e': datetime(2019, 1, 8, 15, 15, tzinfo=tzlocal()), 9 | 'id': 'minimal_event', 10 | 's': datetime(2019, 1, 8, 14, 15, tzinfo=tzlocal()) 11 | } 12 | minimal_event_overlapping = { 13 | 'e': datetime(2019, 1, 8, 16, 15, tzinfo=tzlocal()), 14 | 'id': 'minimal_event_overlapping', 15 | 's': datetime(2019, 1, 8, 14, 30, tzinfo=tzlocal()) 16 | } 17 | minimal_event_nonoverlapping = { 18 | 'e': datetime(2019, 1, 8, 16, 15, tzinfo=tzlocal()), 19 | 'id': 'minimal_event_nonoverlapping', 20 | 's': datetime(2019, 1, 8, 15, 30, tzinfo=tzlocal()) 21 | } 22 | 23 | 24 | def test_finds_no_conflicts_for_one_event(): 25 | """Basic test that only ensures the function can be run without error""" 26 | conflicts = [] 27 | show_conflicts = ShowConflicts(conflicts.append) 28 | show_conflicts.show_conflicts(minimal_event) 29 | assert conflicts == [] 30 | 31 | 32 | def test_finds_conflicts_for_second_overlapping_event(): 33 | conflicts = [] 34 | show_conflicts = ShowConflicts(conflicts.append) 35 | show_conflicts.show_conflicts(minimal_event) 36 | show_conflicts.show_conflicts(minimal_event_overlapping) 37 | assert conflicts == [minimal_event] 38 | 39 | 40 | def test_does_not_find_conflict_for_second_non_overlapping_event(): 41 | conflicts = [] 42 | show_conflicts = ShowConflicts(conflicts.append) 43 | show_conflicts.show_conflicts(minimal_event) 44 | show_conflicts.show_conflicts(minimal_event_nonoverlapping) 45 | assert conflicts == [] 46 | -------------------------------------------------------------------------------- /tests/test_gcalcli.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import io 4 | import os 5 | import re 6 | from datetime import datetime 7 | from json import load 8 | 9 | from gcalcli.argparsers import ( 10 | get_cal_query_parser, 11 | get_color_parser, 12 | get_conflicts_parser, 13 | get_output_parser, 14 | get_search_parser, 15 | get_start_end_parser, 16 | get_updates_parser, 17 | ) 18 | from gcalcli.cli import parse_cal_names 19 | from gcalcli.utils import parse_reminder 20 | 21 | TEST_DATA_DIR = os.path.dirname(os.path.abspath(__file__)) + '/data' 22 | 23 | 24 | # TODO: These are more like placeholders for proper unit tests 25 | # We just try the commands and make sure no errors occur. 26 | def test_list(capsys, PatchedGCalI): 27 | gcal = PatchedGCalI(**vars(get_color_parser().parse_args([]))) 28 | with open(TEST_DATA_DIR + '/cal_list.json') as cl: 29 | cal_count = len(load(cl)['items']) 30 | 31 | # test data has 6 cals 32 | assert cal_count == len(gcal.all_cals) 33 | expected_header = gcal.printer.get_colorcode( 34 | gcal.options['color_title']) + ' Access Title\n' 35 | 36 | gcal.ListAllCalendars() 37 | captured = capsys.readouterr() 38 | assert captured.out.startswith(expected_header) 39 | 40 | # +3 cos one for the header, one for the '----' decorations, 41 | # and one for the eom 42 | assert len(captured.out.split('\n')) == cal_count + 3 43 | 44 | 45 | def test_agenda(PatchedGCalI): 46 | assert PatchedGCalI().AgendaQuery() == 0 47 | 48 | opts = get_start_end_parser().parse_args(['tomorrow']) 49 | assert PatchedGCalI().AgendaQuery(start=opts.start, end=opts.end) == 0 50 | 51 | opts = get_start_end_parser().parse_args(['today', 'tomorrow']) 52 | assert PatchedGCalI().AgendaQuery(start=opts.start, end=opts.end) == 0 53 | 54 | 55 | def test_updates(PatchedGCalI): 56 | since = datetime(2019, 7, 10) 57 | assert PatchedGCalI().UpdatesQuery(since) == 0 58 | 59 | opts = get_updates_parser().parse_args( 60 | ['2019-07-10', '2019-07-19', '2019-08-01']) 61 | assert PatchedGCalI().UpdatesQuery( 62 | last_updated_datetime=opts.since, 63 | start=opts.start, 64 | end=opts.end) == 0 65 | 66 | 67 | def test_conflicts(PatchedGCalI): 68 | assert PatchedGCalI().ConflictsQuery() == 0 69 | 70 | opts = get_conflicts_parser().parse_args( 71 | ['search text', '2019-07-19', '2019-08-01']) 72 | assert PatchedGCalI().ConflictsQuery( 73 | 'search text', 74 | start=opts.start, 75 | end=opts.end) == 0 76 | 77 | 78 | def test_cal_query(capsys, PatchedGCalI): 79 | opts = vars(get_cal_query_parser().parse_args([])) 80 | opts.update(vars(get_output_parser().parse_args([]))) 81 | opts.update(vars(get_color_parser().parse_args([]))) 82 | gcal = PatchedGCalI(**opts) 83 | 84 | gcal.CalQuery('calw') 85 | captured = capsys.readouterr() 86 | art = gcal.printer.art 87 | expect_top = ( 88 | gcal.printer.colors[gcal.options['color_border']] + art['ulc'] + 89 | art['hrz'] * gcal.width['day']) 90 | assert captured.out.startswith(expect_top) 91 | 92 | gcal.CalQuery('calm') 93 | captured = capsys.readouterr() 94 | assert captured.out.startswith(expect_top) 95 | 96 | 97 | def test_add_event(PatchedGCalI): 98 | cal_names = parse_cal_names(['jcrowgey@uw.edu'], printer=None) 99 | gcal = PatchedGCalI( 100 | cal_names=cal_names, allday=False, default_reminders=True) 101 | assert gcal.AddEvent(title='test event', 102 | where='anywhere', 103 | start='now', 104 | end='tomorrow', 105 | descr='testing', 106 | who='anyone', 107 | reminders=None, 108 | color='banana') 109 | 110 | 111 | def test_add_event_with_cal_prompt(PatchedGCalI, capsys, monkeypatch): 112 | cal_names = parse_cal_names( 113 | ['jcrowgey@uw.edu', 'joshuacrowgey@gmail.com'], None) 114 | gcal = PatchedGCalI( 115 | cal_names=cal_names, allday=False, default_reminders=True) 116 | # Fake selecting calendar 0 at the prompt 117 | monkeypatch.setattr('sys.stdin', io.StringIO('0\n')) 118 | assert gcal.AddEvent(title='test event', 119 | where='', 120 | start='now', 121 | end='tomorrow', 122 | descr='', 123 | who='', 124 | reminders=None, 125 | color='') 126 | captured = capsys.readouterr() 127 | assert re.match( 128 | r'(?sm)^0 .*\n1 .*\n.*Specify calendar.*$', captured.out), \ 129 | f'Unexpected stderr: {captured.out}' 130 | 131 | 132 | def test_add_event_override_color(capsys, default_options, 133 | PatchedGCalIForEvents): 134 | default_options.update({'override_color': True}) 135 | cal_names = parse_cal_names(['jcrowgey@uw.edu'], None) 136 | gcal = PatchedGCalIForEvents(cal_names=cal_names, **default_options) 137 | gcal.AgendaQuery() 138 | captured = capsys.readouterr() 139 | # this could be parameterized with pytest eventually 140 | # assert colorId 10: green 141 | assert '\033[0;32m' in captured.out 142 | 143 | 144 | def test_quick_add(PatchedGCalI): 145 | cal_names = parse_cal_names(['jcrowgey@uw.edu'], None) 146 | gcal = PatchedGCalI(cal_names=cal_names) 147 | assert gcal.QuickAddEvent( 148 | event_text='quick test event', 149 | reminders=['5m sms']) 150 | 151 | 152 | def test_quick_add_with_cal_prompt(PatchedGCalI, capsys, monkeypatch): 153 | cal_names = parse_cal_names( 154 | ['jcrowgey@uw.edu', 'joshuacrowgey@gmail.com'], None) 155 | gcal = PatchedGCalI(cal_names=cal_names) 156 | # Fake selecting calendar 0 at the prompt 157 | monkeypatch.setattr('sys.stdin', io.StringIO('0\n')) 158 | assert gcal.QuickAddEvent( 159 | event_text='quick test event', 160 | reminders=['5m sms']) 161 | captured = capsys.readouterr() 162 | assert re.match( 163 | r'(?sm)^0 .*\n1 .*\n.*Specify calendar.*$', captured.out), \ 164 | f'Unexpected stderr: {captured.out}' 165 | 166 | 167 | def test_text_query(PatchedGCalI): 168 | search_parser = get_search_parser() 169 | gcal = PatchedGCalI() 170 | 171 | # TODO: mock the api reply for the search 172 | # and then assert something greater than zero 173 | 174 | opts = search_parser.parse_args(['test', '1970-01-01', '2038-01-18']) 175 | assert gcal.TextQuery(opts.text, opts.start, opts.end) == 0 176 | 177 | opts = search_parser.parse_args(['test', '1970-01-01']) 178 | assert gcal.TextQuery(opts.text, opts.start, opts.end) == 0 179 | 180 | opts = search_parser.parse_args(['test']) 181 | assert gcal.TextQuery(opts.text, opts.start, opts.end) == 0 182 | 183 | 184 | def test_declined_event_no_attendees(PatchedGCalI): 185 | gcal = PatchedGCalI() 186 | event = { 187 | 'gcalcli_cal': { 188 | 'id': 'user@email.com', 189 | }, 190 | 'attendees': [] 191 | } 192 | assert not gcal._DeclinedEvent(event) 193 | 194 | 195 | def test_declined_event_non_matching_attendees(PatchedGCalI): 196 | gcal = PatchedGCalI() 197 | event = { 198 | 'gcalcli_cal': { 199 | 'id': 'user@email.com', 200 | }, 201 | 'attendees': [{ 202 | 'email': 'user2@otheremail.com', 203 | 'responseStatus': 'declined', 204 | }] 205 | } 206 | assert not gcal._DeclinedEvent(event) 207 | 208 | 209 | def test_declined_event_matching_attendee_declined(PatchedGCalI): 210 | gcal = PatchedGCalI() 211 | event = { 212 | 'gcalcli_cal': { 213 | 'id': 'user@email.com', 214 | }, 215 | 'attendees': [ 216 | { 217 | 'email': 'user@email.com', 218 | 'responseStatus': 'declined', 219 | }, 220 | { 221 | 'email': 'user2@otheremail.com', 222 | 'responseStatus': 'accepted', 223 | }, 224 | ] 225 | } 226 | assert gcal._DeclinedEvent(event) 227 | 228 | 229 | def test_declined_event_matching_attendee_accepted(PatchedGCalI): 230 | gcal = PatchedGCalI() 231 | event = { 232 | 'gcalcli_cal': { 233 | 'id': 'user@email.com', 234 | }, 235 | 'attendees': [ 236 | { 237 | 'email': 'user@email.com', 238 | 'responseStatus': 'accepted', 239 | }, 240 | { 241 | 'email': 'user2@otheremail.com', 242 | 'responseStatus': 'declined', 243 | }, 244 | ] 245 | } 246 | assert not gcal._DeclinedEvent(event) 247 | 248 | 249 | def test_declined_event_aliased_attendee(PatchedGCalI): 250 | """Should detect declined events if attendee has self=True (#620).""" 251 | gcal = PatchedGCalI() 252 | event = { 253 | 'gcalcli_cal': { 254 | 'id': 'user@email.com', 255 | }, 256 | 'attendees': [ 257 | { 258 | 'email': 'user@otherdomain.com', 259 | 'self': True, 260 | 'responseStatus': 'declined', 261 | }, 262 | ] 263 | } 264 | assert gcal._DeclinedEvent(event), \ 265 | "Must detect declined 'self' events regardless of email" 266 | 267 | 268 | def test_modify_event(PatchedGCalI): 269 | opts = get_search_parser().parse_args(['test']) 270 | gcal = PatchedGCalI(**vars(opts)) 271 | assert gcal.ModifyEvents( 272 | gcal._edit_event, opts.text, opts.start, opts.end) == 0 273 | 274 | 275 | def test_import(PatchedGCalI): 276 | cal_names = parse_cal_names(['jcrowgey@uw.edu'], None) 277 | gcal = PatchedGCalI(cal_names=cal_names, default_reminders=True) 278 | vcal_path = TEST_DATA_DIR + '/vv.txt' 279 | assert gcal.ImportICS(icsFile=open(vcal_path, errors='replace')) 280 | 281 | 282 | def test_legacy_import(PatchedGCalI): 283 | cal_names = parse_cal_names(['jcrowgey@uw.edu'], None) 284 | gcal = PatchedGCalI( 285 | cal_names=cal_names, default_reminders=True, use_legacy_import=True) 286 | vcal_path = TEST_DATA_DIR + '/vv.txt' 287 | assert gcal.ImportICS(icsFile=open(vcal_path, errors='replace')) 288 | 289 | 290 | def test_parse_reminder(): 291 | MINS_PER_DAY = 60 * 24 292 | MINS_PER_WEEK = MINS_PER_DAY * 7 293 | 294 | rem = '5m email' 295 | tim, method = parse_reminder(rem) 296 | assert method == 'email' 297 | assert tim == 5 298 | 299 | rem = '2h sms' 300 | tim, method = parse_reminder(rem) 301 | assert method == 'sms' 302 | assert tim == 120 303 | 304 | rem = '1d popup' 305 | tim, method = parse_reminder(rem) 306 | assert method == 'popup' 307 | assert tim == MINS_PER_DAY 308 | 309 | rem = '1w' 310 | tim, method = parse_reminder(rem) 311 | assert method == 'popup' 312 | assert tim == MINS_PER_WEEK 313 | 314 | rem = '10w' 315 | tim, method = parse_reminder(rem) 316 | assert method == 'popup' 317 | assert tim == MINS_PER_WEEK * 10 318 | 319 | rem = 'invalid reminder' 320 | assert parse_reminder(rem) is None 321 | 322 | 323 | def test_parse_cal_names(PatchedGCalI): 324 | # TODO we need to mock the event list returned by the search 325 | # and then assert the right number of events 326 | # for the moment, we assert 0 (which indicates successful completion of 327 | # the code path, but no events printed) 328 | cal_names = parse_cal_names(['j*#green'], None) 329 | gcal = PatchedGCalI(cal_names=cal_names) 330 | assert gcal.AgendaQuery() == 0 331 | 332 | cal_names = parse_cal_names(['j*'], None) 333 | gcal = PatchedGCalI(cal_names=cal_names) 334 | assert gcal.AgendaQuery() == 0 335 | 336 | cal_names = parse_cal_names(['jcrowgey@uw.edu'], None) 337 | gcal = PatchedGCalI(cal_names=cal_names) 338 | assert gcal.AgendaQuery() == 0 339 | 340 | 341 | def test_iterate_events(capsys, PatchedGCalI): 342 | gcal = PatchedGCalI() 343 | assert gcal._iterate_events(gcal.now, []) == 0 344 | 345 | # TODO: add some events to a list and assert their selection 346 | 347 | 348 | def test_next_cut(PatchedGCalI): 349 | gcal = PatchedGCalI() 350 | # default width is 10 351 | test_day_width = 10 352 | gcal.width['day'] = test_day_width 353 | event_title = "first looooong" 354 | assert gcal._next_cut(event_title) == (5, 5) 355 | 356 | event_title = "tooooooooooooooooooooooooloooooooooong" 357 | assert gcal._next_cut(event_title) == (test_day_width, test_day_width) 358 | 359 | event_title = "one two three four" 360 | assert gcal._next_cut(event_title) == (7, 7) 361 | 362 | # event_title = "& G NSW VIM Project" 363 | # assert gcal._next_cut(event_title) == (7, 7) 364 | 365 | event_title = "樹貞 fun fun fun" 366 | assert gcal._next_cut(event_title) == (8, 6) 367 | -------------------------------------------------------------------------------- /tests/test_input_validation.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from gcalcli.validators import (PARSABLE_DATE, PARSABLE_DURATION, REMINDER, 4 | STR_ALLOW_EMPTY, STR_NOT_EMPTY, STR_TO_INT, 5 | VALID_COLORS, ValidationError) 6 | 7 | # Tests required: 8 | # 9 | # * Title: any string, not blank 10 | # * Location: any string, allow blank 11 | # * When: string that can be parsed by dateutil 12 | # * Duration: string that can be cast to int 13 | # * Description: any string, allow blank 14 | # * Color: any string matching: blueberry, lavendar, grape, etc, or blank 15 | # * Reminder: a valid reminder 16 | 17 | 18 | def test_any_string_not_blank_validator(monkeypatch): 19 | # Empty string raises ValidationError 20 | monkeypatch.setattr("builtins.input", lambda: "") 21 | with pytest.raises(ValidationError): 22 | STR_NOT_EMPTY(input()) == ValidationError( 23 | "Input here cannot be empty") 24 | 25 | # None raises ValidationError 26 | monkeypatch.setattr("builtins.input", lambda: None) 27 | with pytest.raises(ValidationError): 28 | STR_NOT_EMPTY(input()) == ValidationError( 29 | "Input here cannot be empty") 30 | 31 | # Valid string passes 32 | monkeypatch.setattr("builtins.input", lambda: "Valid Text") 33 | assert STR_NOT_EMPTY(input()) == "Valid Text" 34 | 35 | 36 | def test_any_string_parsable_by_dateutil(monkeypatch): 37 | # non-date raises ValidationError 38 | monkeypatch.setattr("builtins.input", lambda: "NON-DATE STR") 39 | with pytest.raises(ValidationError): 40 | PARSABLE_DATE(input()) == ValidationError( 41 | "Expected format: a date (e.g. 2019-01-01, tomorrow 10am, " 42 | "2nd Jan, Jan 4th, etc) or valid time if today. " 43 | "(Ctrl-C to exit)\n" 44 | ) 45 | 46 | # date string passes 47 | monkeypatch.setattr("builtins.input", lambda: "2nd January") 48 | PARSABLE_DATE(input()) == "2nd January" 49 | 50 | 51 | def test_any_string_parsable_by_parsedatetime(monkeypatch): 52 | # non-date raises ValidationError 53 | monkeypatch.setattr("builtins.input", lambda: "NON-DATE STR") 54 | with pytest.raises(ValidationError) as ve: 55 | PARSABLE_DURATION(input()) 56 | assert ve.value.message == ( 57 | 'Expected format: a duration (e.g. 1m, 1s, 1h3m)' 58 | '(Ctrl-C to exit)\n' 59 | ) 60 | 61 | # duration string passes 62 | monkeypatch.setattr("builtins.input", lambda: "1m") 63 | assert PARSABLE_DURATION(input()) == "1m" 64 | 65 | # duration string passes 66 | monkeypatch.setattr("builtins.input", lambda: "1h2m") 67 | assert PARSABLE_DURATION(input()) == "1h2m" 68 | 69 | 70 | def test_string_can_be_cast_to_int(monkeypatch): 71 | # non int-castable string raises ValidationError 72 | monkeypatch.setattr("builtins.input", lambda: "X") 73 | with pytest.raises(ValidationError): 74 | STR_TO_INT(input()) == ValidationError( 75 | "Input here must be a number") 76 | 77 | # int string passes 78 | monkeypatch.setattr("builtins.input", lambda: "10") 79 | STR_TO_INT(input()) == "10" 80 | 81 | 82 | def test_for_valid_colour_name(monkeypatch): 83 | # non valid colour raises ValidationError 84 | monkeypatch.setattr("builtins.input", lambda: "purple") 85 | with pytest.raises(ValidationError): 86 | VALID_COLORS(input()) == ValidationError( 87 | "purple is not a valid color value to use here. Please " 88 | "use one of basil, peacock, grape, lavender, blueberry," 89 | "tomato, safe, flamingo or banana." 90 | ) 91 | # valid colour passes 92 | monkeypatch.setattr("builtins.input", lambda: "grape") 93 | VALID_COLORS(input()) == "grape" 94 | 95 | # empty str passes 96 | monkeypatch.setattr("builtins.input", lambda: "") 97 | VALID_COLORS(input()) == "" 98 | 99 | 100 | def test_any_string_and_blank(monkeypatch): 101 | # string passes 102 | monkeypatch.setattr("builtins.input", lambda: "TEST") 103 | STR_ALLOW_EMPTY(input()) == "TEST" 104 | 105 | 106 | def test_reminder(monkeypatch): 107 | # valid reminders pass 108 | monkeypatch.setattr("builtins.input", lambda: "10m email") 109 | REMINDER(input()) == "10m email" 110 | 111 | monkeypatch.setattr("builtins.input", lambda: "10 popup") 112 | REMINDER(input()) == "10m email" 113 | 114 | monkeypatch.setattr("builtins.input", lambda: "10m sms") 115 | REMINDER(input()) == "10m email" 116 | 117 | monkeypatch.setattr("builtins.input", lambda: "12323") 118 | REMINDER(input()) == "10m email" 119 | 120 | # invalid reminder raises ValidationError 121 | monkeypatch.setattr("builtins.input", lambda: "meaningless") 122 | with pytest.raises(ValidationError): 123 | REMINDER(input()) == ValidationError( 124 | "Format: \n") 125 | 126 | # invalid reminder raises ValidationError 127 | monkeypatch.setattr("builtins.input", lambda: "") 128 | with pytest.raises(ValidationError): 129 | REMINDER(input()) == ValidationError( 130 | "Format: \n") 131 | -------------------------------------------------------------------------------- /tests/test_printer.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentTypeError 2 | from io import StringIO 3 | import sys 4 | 5 | import pytest 6 | 7 | from gcalcli.printer import COLOR_NAMES, Printer, valid_color_name 8 | 9 | 10 | def test_init(): 11 | cp = Printer() 12 | assert cp 13 | 14 | 15 | def test_valid_color_name(): 16 | with pytest.raises(ArgumentTypeError): 17 | valid_color_name('this_is_not_a_colorname') 18 | 19 | 20 | def test_all_colors(): 21 | """Makes sure the COLOR_NAMES is in sync with the colors in the printer""" 22 | cp = Printer() 23 | for color_name in COLOR_NAMES: 24 | out = StringIO() 25 | cp.msg('msg', color_name, file=out) 26 | out.seek(0) 27 | assert out.read() == cp.colors[color_name] + 'msg' + '\033[0m' 28 | 29 | 30 | def test_red_msg(): 31 | cp = Printer() 32 | out = StringIO() 33 | cp.msg('msg', 'red', file=out) 34 | out.seek(0) 35 | assert out.read() == '\033[0;31mmsg\033[0m' 36 | 37 | 38 | def test_err_msg(monkeypatch): 39 | err = StringIO() 40 | monkeypatch.setattr(sys, 'stderr', err) 41 | cp = Printer() 42 | cp.err_msg('error') 43 | err.seek(0) 44 | assert err.read() == '\033[31;1merror\033[0m' 45 | 46 | 47 | def test_debug_msg(monkeypatch): 48 | err = StringIO() 49 | monkeypatch.setattr(sys, 'stderr', err) 50 | cp = Printer() 51 | cp.debug_msg('debug') 52 | err.seek(0) 53 | assert err.read() == '\033[0;33mdebug\033[0m' 54 | 55 | 56 | def test_conky_red_msg(): 57 | cp = Printer(conky=True) 58 | out = StringIO() 59 | cp.msg('msg', 'red', file=out) 60 | out.seek(0) 61 | assert out.read() == '${color red}msg${color}' 62 | 63 | 64 | def test_conky_err_msg(monkeypatch): 65 | err = StringIO() 66 | monkeypatch.setattr(sys, 'stderr', err) 67 | cp = Printer(conky=True) 68 | cp.err_msg('error') 69 | err.seek(0) 70 | assert err.read() == '${color red}error${color}' 71 | 72 | 73 | def test_conky_debug_msg(monkeypatch): 74 | err = StringIO() 75 | monkeypatch.setattr(sys, 'stderr', err) 76 | cp = Printer(conky=True) 77 | cp.debug_msg('debug') 78 | err.seek(0) 79 | assert err.read() == '${color yellow}debug${color}' 80 | 81 | 82 | def test_no_color(): 83 | cp = Printer(use_color=False) 84 | out = StringIO() 85 | cp.msg('msg', 'red', file=out) 86 | out.seek(0) 87 | assert out.read() == 'msg' 88 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import pytest 4 | from dateutil.tz import UTC, tzutc 5 | 6 | from gcalcli import utils 7 | 8 | 9 | def test_get_time_from_str(): 10 | assert utils.get_time_from_str('7am tomorrow') 11 | 12 | 13 | def test_get_parsed_timedelta_from_str(): 14 | assert utils.get_timedelta_from_str('3.5h') == timedelta( 15 | hours=3, minutes=30) 16 | assert utils.get_timedelta_from_str('1') == timedelta(minutes=1) 17 | assert utils.get_timedelta_from_str('1m') == timedelta(minutes=1) 18 | assert utils.get_timedelta_from_str('1h') == timedelta(hours=1) 19 | assert utils.get_timedelta_from_str('1h1m') == timedelta( 20 | hours=1, minutes=1) 21 | assert utils.get_timedelta_from_str('1:10') == timedelta( 22 | hours=1, minutes=10) 23 | assert utils.get_timedelta_from_str('2d:1h:3m') == timedelta( 24 | days=2, hours=1, minutes=3) 25 | assert utils.get_timedelta_from_str('2d 1h 3m 10s') == timedelta( 26 | days=2, hours=1, minutes=3, seconds=10) 27 | assert utils.get_timedelta_from_str( 28 | '2 days 1 hour 2 minutes 40 seconds') == timedelta( 29 | days=2, hours=1, minutes=2, seconds=40) 30 | with pytest.raises(ValueError) as ve: 31 | utils.get_timedelta_from_str('junk') 32 | assert str(ve.value) == "Duration is invalid: junk" 33 | 34 | 35 | def test_get_times_from_duration(): 36 | begin_1970 = '1970-01-01' 37 | begin_1970_midnight = begin_1970 + 'T00:00:00+00:00' 38 | two_hrs_later = begin_1970 + 'T02:00:00+00:00' 39 | next_day = '1970-01-02' 40 | assert (begin_1970_midnight, two_hrs_later) == \ 41 | utils.get_times_from_duration(begin_1970_midnight, duration=120) 42 | 43 | assert (begin_1970_midnight, two_hrs_later) == \ 44 | utils.get_times_from_duration( 45 | begin_1970_midnight, duration="2h") 46 | 47 | assert (begin_1970_midnight, two_hrs_later) == \ 48 | utils.get_times_from_duration( 49 | begin_1970_midnight, duration="120m") 50 | 51 | assert (begin_1970, next_day) == \ 52 | utils.get_times_from_duration( 53 | begin_1970_midnight, duration=1, allday=True) 54 | 55 | with pytest.raises(ValueError): 56 | utils.get_times_from_duration('this is not a date') 57 | 58 | with pytest.raises(ValueError): 59 | utils.get_times_from_duration( 60 | begin_1970_midnight, duration='not a duration') 61 | 62 | with pytest.raises(ValueError): 63 | utils.get_times_from_duration( 64 | begin_1970_midnight, duration='not a duraction', allday=True) 65 | 66 | 67 | def test_days_since_epoch(): 68 | assert utils.days_since_epoch(datetime(1970, 1, 1, 0, tzinfo=UTC)) == 0 69 | assert utils.days_since_epoch(datetime(1970, 12, 31)) == 364 70 | 71 | 72 | def test_set_locale(): 73 | with pytest.raises(ValueError): 74 | utils.set_locale('not_a_real_locale') 75 | 76 | 77 | def test_localize_datetime(PatchedGCalI): 78 | dt = utils.localize_datetime(datetime.now()) 79 | assert dt.tzinfo is not None 80 | 81 | dt = datetime.now(tzutc()) 82 | dt = utils.localize_datetime(dt) 83 | assert dt.tzinfo is not None 84 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>=4 4 | envlist = lint, type, py3{10,11,12}, cli 5 | 6 | [testenv] 7 | usedevelop = true 8 | deps = pytest 9 | pytest-cov 10 | coverage 11 | vobject 12 | extras = dev 13 | 14 | commands=py.test -vv --cov=./gcalcli --pyargs tests {posargs} 15 | coverage html 16 | 17 | [testenv:lint] 18 | description = run linters 19 | skip_install = true 20 | deps = ruff 21 | commands = ruff check 22 | 23 | [testenv:type] 24 | description = run type checks 25 | deps = 26 | mypy >= 1.0 27 | commands = 28 | mypy {posargs:gcalcli} --cache-fine-grained 29 | 30 | [testenv:cli] 31 | description = run functional tests using Bats 32 | allowlist_externals = 33 | ./tests/cli/run_tests.sh 34 | commands = 35 | ./tests/cli/run_tests.sh {posargs:tests/cli/test.bats} 36 | 37 | [gh] 38 | python = 39 | 3.12 = py312, lint, type, cli 40 | 3.11 = py311 41 | 3.10 = py310, type 42 | --------------------------------------------------------------------------------