From da09b7257567677e5f778257395419d134676496 Mon Sep 17 00:00:00 2001 From: David Barnett Date: Mon, 16 Sep 2024 22:28:20 -0600 Subject: [PATCH 1/2] In `add` prompt for "when", offer "?" option to show help hint --- gcalcli/cli.py | 18 ++++++-- gcalcli/validators.py | 24 +++++------ .../test-04-test_can_run_add.snap | 2 +- tests/test_input_validation.py | 42 +++++++++---------- 4 files changed, 49 insertions(+), 37 deletions(-) diff --git a/gcalcli/cli.py b/gcalcli/cli.py index a76e5abe..cc0c6e2f 100755 --- a/gcalcli/cli.py +++ b/gcalcli/cli.py @@ -36,8 +36,15 @@ from .exceptions import GcalcliError from .gcal import GoogleCalendarInterface from .printer import Printer, valid_color_name -from .validators import (get_input, PARSABLE_DATE, PARSABLE_DURATION, REMINDER, - STR_ALLOW_EMPTY, STR_NOT_EMPTY) +from .validators import ( + DATETIME_INPUT_DESCRIPTION, + get_input, + PARSABLE_DATE, + PARSABLE_DURATION, + REMINDER, + STR_ALLOW_EMPTY, + STR_NOT_EMPTY, +) CalName = namedtuple('CalName', ['name', 'color']) @@ -108,7 +115,12 @@ def run_add_prompt(parsed_args, printer): if parsed_args.where is None: parsed_args.where = get_input(printer, 'Location: ', STR_ALLOW_EMPTY) if parsed_args.when is None: - parsed_args.when = get_input(printer, 'When: ', PARSABLE_DATE) + parsed_args.when = get_input( + printer, + 'When (? for help): ', + PARSABLE_DATE, + help=f'Expected format: {DATETIME_INPUT_DESCRIPTION}', + ) if parsed_args.duration is None and parsed_args.end is None: if parsed_args.allday: prompt = 'Duration (days): ' diff --git a/gcalcli/validators.py b/gcalcli/validators.py index 437ed58d..810131a6 100644 --- a/gcalcli/validators.py +++ b/gcalcli/validators.py @@ -1,4 +1,5 @@ import re +from typing import Optional from .exceptions import ValidationError from .utils import get_time_from_str, get_timedelta_from_str, REMINDER_REGEX @@ -9,16 +10,24 @@ 'banana', 'tangerine', 'peacock', 'graphite', 'blueberry', 'basil', 'tomato'] +DATETIME_INPUT_DESCRIPTION = 'a date (e.g. 2019-12-31, tomorrow 10am, 2nd Jan, \ +Jan 4th, etc) or valid time if today' + def get_override_color_id(color): return str(VALID_OVERRIDE_COLORS.index(color) + 1) -def get_input(printer, prompt, validator_func): +def get_input(printer, prompt, validator_func, help: Optional[str] = None): printer.msg(prompt, 'magenta') while True: try: - output = validate_input(validator_func) + answer = input() + if answer.strip() == '?' and help: + printer.msg(f'{help}\n') + printer.msg(prompt, 'magenta') + continue + output = validator_func(answer) return output except ValidationError as e: printer.msg(e.message, 'red') @@ -68,8 +77,7 @@ def parsable_date_validator(input_str): return input_str except ValueError: raise ValidationError( - 'Expected format: a date (e.g. 2019-01-01, tomorrow 10am, ' - '2nd Jan, Jan 4th, etc) or valid time if today. ' + f'Expected format: {DATETIME_INPUT_DESCRIPTION}. ' '(Ctrl-C to exit)\n' ) @@ -124,14 +132,6 @@ def reminder_validator(input_str): '. (Ctrl-C to exit)\n') -def validate_input(validator_func): - """ - Wrapper around Validator funcs. - """ - inp_str = input() - return validator_func(inp_str) - - STR_NOT_EMPTY = non_blank_str_validator STR_ALLOW_EMPTY = str_allow_empty_validator STR_TO_INT = str_to_int_validator diff --git a/tests/cli/__snapshot__/test-04-test_can_run_add.snap b/tests/cli/__snapshot__/test-04-test_can_run_add.snap index 44825dfe..0b7bc1d8 100644 --- a/tests/cli/__snapshot__/test-04-test_can_run_add.snap +++ b/tests/cli/__snapshot__/test-04-test_can_run_add.snap @@ -2,4 +2,4 @@ Running in GCALCLI_USERLESS_MODE. Most operations will fail! Prompting for unfilled values. Run with --noprompt to leave them unfilled without prompting. -Title: Location: When: Duration (human readable): Description: Enter a valid reminder or "." to end: No available calendar to use +Title: Location: When (? for help): Duration (human readable): Description: Enter a valid reminder or "." to end: No available calendar to use diff --git a/tests/test_input_validation.py b/tests/test_input_validation.py index ec0fd6b7..2168c5d4 100644 --- a/tests/test_input_validation.py +++ b/tests/test_input_validation.py @@ -2,7 +2,7 @@ from gcalcli.validators import (PARSABLE_DATE, PARSABLE_DURATION, REMINDER, STR_ALLOW_EMPTY, STR_NOT_EMPTY, STR_TO_INT, - VALID_COLORS, validate_input, ValidationError) + VALID_COLORS, ValidationError) # Tests required: # @@ -19,25 +19,25 @@ def test_any_string_not_blank_validator(monkeypatch): # Empty string raises ValidationError monkeypatch.setattr("builtins.input", lambda: "") with pytest.raises(ValidationError): - validate_input(STR_NOT_EMPTY) == ValidationError( + STR_NOT_EMPTY(input()) == ValidationError( "Input here cannot be empty") # None raises ValidationError monkeypatch.setattr("builtins.input", lambda: None) with pytest.raises(ValidationError): - validate_input(STR_NOT_EMPTY) == ValidationError( + STR_NOT_EMPTY(input()) == ValidationError( "Input here cannot be empty") # Valid string passes monkeypatch.setattr("builtins.input", lambda: "Valid Text") - assert validate_input(STR_NOT_EMPTY) == "Valid Text" + assert STR_NOT_EMPTY(input()) == "Valid Text" def test_any_string_parsable_by_dateutil(monkeypatch): # non-date raises ValidationError monkeypatch.setattr("builtins.input", lambda: "NON-DATE STR") with pytest.raises(ValidationError): - validate_input(PARSABLE_DATE) == ValidationError( + PARSABLE_DATE(input()) == ValidationError( "Expected format: a date (e.g. 2019-01-01, tomorrow 10am, " "2nd Jan, Jan 4th, etc) or valid time if today. " "(Ctrl-C to exit)\n" @@ -45,14 +45,14 @@ def test_any_string_parsable_by_dateutil(monkeypatch): # date string passes monkeypatch.setattr("builtins.input", lambda: "2nd January") - validate_input(PARSABLE_DATE) == "2nd January" + PARSABLE_DATE(input()) == "2nd January" def test_any_string_parsable_by_parsedatetime(monkeypatch): # non-date raises ValidationError monkeypatch.setattr("builtins.input", lambda: "NON-DATE STR") with pytest.raises(ValidationError) as ve: - validate_input(PARSABLE_DURATION) + PARSABLE_DURATION(input()) assert ve.value.message == ( 'Expected format: a duration (e.g. 1m, 1s, 1h3m)' '(Ctrl-C to exit)\n' @@ -60,71 +60,71 @@ def test_any_string_parsable_by_parsedatetime(monkeypatch): # duration string passes monkeypatch.setattr("builtins.input", lambda: "1m") - assert validate_input(PARSABLE_DURATION) == "1m" + assert PARSABLE_DURATION(input()) == "1m" # duration string passes monkeypatch.setattr("builtins.input", lambda: "1h2m") - assert validate_input(PARSABLE_DURATION) == "1h2m" + assert PARSABLE_DURATION(input()) == "1h2m" def test_string_can_be_cast_to_int(monkeypatch): # non int-castable string raises ValidationError monkeypatch.setattr("builtins.input", lambda: "X") with pytest.raises(ValidationError): - validate_input(STR_TO_INT) == ValidationError( + STR_TO_INT(input()) == ValidationError( "Input here must be a number") # int string passes monkeypatch.setattr("builtins.input", lambda: "10") - validate_input(STR_TO_INT) == "10" + STR_TO_INT(input()) == "10" def test_for_valid_colour_name(monkeypatch): # non valid colour raises ValidationError monkeypatch.setattr("builtins.input", lambda: "purple") with pytest.raises(ValidationError): - validate_input(VALID_COLORS) == ValidationError( + VALID_COLORS(input()) == ValidationError( "purple is not a valid color value to use here. Please " "use one of basil, peacock, grape, lavender, blueberry," "tomato, safe, flamingo or banana." ) # valid colour passes monkeypatch.setattr("builtins.input", lambda: "grape") - validate_input(VALID_COLORS) == "grape" + VALID_COLORS(input()) == "grape" # empty str passes monkeypatch.setattr("builtins.input", lambda: "") - validate_input(VALID_COLORS) == "" + VALID_COLORS(input()) == "" def test_any_string_and_blank(monkeypatch): # string passes monkeypatch.setattr("builtins.input", lambda: "TEST") - validate_input(STR_ALLOW_EMPTY) == "TEST" + STR_ALLOW_EMPTY(input()) == "TEST" def test_reminder(monkeypatch): # valid reminders pass monkeypatch.setattr("builtins.input", lambda: "10m email") - validate_input(REMINDER) == "10m email" + REMINDER(input()) == "10m email" monkeypatch.setattr("builtins.input", lambda: "10 popup") - validate_input(REMINDER) == "10m email" + REMINDER(input()) == "10m email" monkeypatch.setattr("builtins.input", lambda: "10m sms") - validate_input(REMINDER) == "10m email" + REMINDER(input()) == "10m email" monkeypatch.setattr("builtins.input", lambda: "12323") - validate_input(REMINDER) == "10m email" + REMINDER(input()) == "10m email" # invalid reminder raises ValidationError monkeypatch.setattr("builtins.input", lambda: "meaningless") with pytest.raises(ValidationError): - validate_input(REMINDER) == ValidationError( + REMINDER(input()) == ValidationError( "Format: \n") # invalid reminder raises ValidationError monkeypatch.setattr("builtins.input", lambda: "") with pytest.raises(ValidationError): - validate_input(REMINDER) == ValidationError( + REMINDER(input()) == ValidationError( "Format: \n") From 95c2f5e3aa22b040daf5d14931b7e4cc1c0849c6 Mon Sep 17 00:00:00 2001 From: David Barnett Date: Tue, 17 Sep 2024 09:51:26 -0600 Subject: [PATCH 2/2] Adjust date format for "When" parameters to correspond to system locale --- ChangeLog | 1 + gcalcli/cli.py | 5 +++-- gcalcli/utils.py | 25 ++++++++++++++++++++++++- gcalcli/validators.py | 20 +++++++++++++++----- pyproject.toml | 1 + 5 files changed, 44 insertions(+), 8 deletions(-) diff --git a/ChangeLog b/ChangeLog index a3b43f24..24feffb2 100644 --- a/ChangeLog +++ b/ChangeLog @@ -7,6 +7,7 @@ v4.5.0 w/ or w/o --config-folder - POSSIBLE ACTION REQUIRED: Use `@path/to/gcalclirc` explicitly if it stops reading an rc file you needed + * Determine date format to use based on system locale's in "When" inputs * Respect locally-installed certificates (ajkessel) * Re-add a `--noauth_local_server` to provide instructions for authenticating from a remote system using port forwarding diff --git a/gcalcli/cli.py b/gcalcli/cli.py index cc0c6e2f..bca93b91 100755 --- a/gcalcli/cli.py +++ b/gcalcli/cli.py @@ -37,7 +37,7 @@ from .gcal import GoogleCalendarInterface from .printer import Printer, valid_color_name from .validators import ( - DATETIME_INPUT_DESCRIPTION, + get_date_input_description, get_input, PARSABLE_DATE, PARSABLE_DURATION, @@ -115,11 +115,12 @@ def run_add_prompt(parsed_args, printer): if parsed_args.where is None: parsed_args.where = get_input(printer, 'Location: ', STR_ALLOW_EMPTY) if parsed_args.when is None: + date_format_desc = get_date_input_description() parsed_args.when = get_input( printer, 'When (? for help): ', PARSABLE_DATE, - help=f'Expected format: {DATETIME_INPUT_DESCRIPTION}', + help=f'Expected format: {date_format_desc}', ) if parsed_args.duration is None and parsed_args.end is None: if parsed_args.allday: diff --git a/gcalcli/utils.py b/gcalcli/utils.py index d558f0b1..f94cd4bb 100644 --- a/gcalcli/utils.py +++ b/gcalcli/utils.py @@ -8,6 +8,7 @@ from datetime import datetime, timedelta from typing import Tuple +import babel from dateutil.parser import parse as dateutil_parse from dateutil.tz import tzlocal from parsedatetime.parsedatetime import Calendar @@ -85,6 +86,26 @@ def get_times_from_duration( return start.isoformat(), stop.isoformat() +def is_dayfirst_locale(): + """Detect whether system locale date format has day first. + + Examples: + - M/d/yy -> False + - dd/MM/yy -> True + - (UnknownLocaleError) -> False + + Pattern syntax is documented at + https://babel.pocoo.org/en/latest/dates.html#pattern-syntax. + """ + try: + locale = babel.Locale(babel.default_locale('LC_TIME')) + except babel.UnknownLocaleError: + # Couldn't detect locale, assume non-dayfirst. + return False + m = re.search(r'M|d|$', locale.date_formats['short'].pattern) + return m and m.group(0) == 'd' + + def get_time_from_str(when): """Convert a string to a time: first uses the dateutil parser, falls back on fuzzy matching with parsedatetime @@ -93,7 +114,9 @@ def get_time_from_str(when): hour=0, minute=0, second=0, microsecond=0) try: - event_time = dateutil_parse(when, default=zero_oclock_today) + event_time = dateutil_parse( + when, default=zero_oclock_today, dayfirst=is_dayfirst_locale() + ) except ValueError: struct, result = fuzzy_date_parse(when) if not result: diff --git a/gcalcli/validators.py b/gcalcli/validators.py index 810131a6..95a7187f 100644 --- a/gcalcli/validators.py +++ b/gcalcli/validators.py @@ -2,7 +2,12 @@ from typing import Optional from .exceptions import ValidationError -from .utils import get_time_from_str, get_timedelta_from_str, REMINDER_REGEX +from .utils import ( + get_time_from_str, + get_timedelta_from_str, + REMINDER_REGEX, + is_dayfirst_locale, +) # TODO: in the future, pull these from the API # https://developers.google.com/calendar/v3/reference/colors @@ -10,9 +15,6 @@ 'banana', 'tangerine', 'peacock', 'graphite', 'blueberry', 'basil', 'tomato'] -DATETIME_INPUT_DESCRIPTION = 'a date (e.g. 2019-12-31, tomorrow 10am, 2nd Jan, \ -Jan 4th, etc) or valid time if today' - def get_override_color_id(color): return str(VALID_OVERRIDE_COLORS.index(color) + 1) @@ -66,6 +68,13 @@ def str_to_int_validator(input_str): ) +def get_date_input_description(): + dayfirst = is_dayfirst_locale() + sample_date = '2019-31-12' if dayfirst else '2019-12-31' + return f'a date (e.g. {sample_date}, tomorrow 10am, 2nd Jan, Jan 4th, etc) \ +or valid time if today' + + def parsable_date_validator(input_str): """ A filter allowing any string which can be parsed @@ -76,8 +85,9 @@ def parsable_date_validator(input_str): get_time_from_str(input_str) return input_str except ValueError: + format_desc = get_date_input_description() raise ValidationError( - f'Expected format: {DATETIME_INPUT_DESCRIPTION}. ' + f'Expected format: {format_desc}. ' '(Ctrl-C to exit)\n' ) diff --git a/pyproject.toml b/pyproject.toml index 47a62ed0..5da15535 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ classifiers = [ ] dependencies = [ "argcomplete", + "babel", "google-api-python-client>=1.4", "google_auth_oauthlib", "httplib2",