Skip to content

Commit

Permalink
Merge pull request #762 from insanum/datefmt
Browse files Browse the repository at this point in the history
Determine date format to use based on system locale's in "When" inputs
  • Loading branch information
dbarnett authored Sep 17, 2024
2 parents 0b89828 + 95c2f5e commit 093e6da
Show file tree
Hide file tree
Showing 7 changed files with 87 additions and 39 deletions.
1 change: 1 addition & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions gcalcli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
get_date_input_description,
get_input,
PARSABLE_DATE,
PARSABLE_DURATION,
REMINDER,
STR_ALLOW_EMPTY,
STR_NOT_EMPTY,
)

CalName = namedtuple('CalName', ['name', 'color'])

Expand Down Expand Up @@ -108,7 +115,13 @@ 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)
date_format_desc = get_date_input_description()
parsed_args.when = get_input(
printer,
'When (? for help): ',
PARSABLE_DATE,
help=f'Expected format: {date_format_desc}',
)
if parsed_args.duration is None and parsed_args.end is None:
if parsed_args.allday:
prompt = 'Duration (days): '
Expand Down
25 changes: 24 additions & 1 deletion gcalcli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
36 changes: 23 additions & 13 deletions gcalcli/validators.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import re
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
Expand All @@ -14,11 +20,16 @@ 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')
Expand Down Expand Up @@ -57,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
Expand All @@ -67,9 +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(
'Expected format: a date (e.g. 2019-01-01, tomorrow 10am, '
'2nd Jan, Jan 4th, etc) or valid time if today. '
f'Expected format: {format_desc}. '
'(Ctrl-C to exit)\n'
)

Expand Down Expand Up @@ -124,14 +142,6 @@ def reminder_validator(input_str):
'<popup|email|sms>. (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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ classifiers = [
]
dependencies = [
"argcomplete",
"babel",
"google-api-python-client>=1.4",
"google_auth_oauthlib",
"httplib2",
Expand Down
2 changes: 1 addition & 1 deletion tests/cli/__snapshot__/test-04-test_can_run_add.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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.
[0m[0;35mTitle: [0m[0;35mLocation: [0m[0;35mWhen: [0m[0;35mDuration (human readable): [0m[0;35mDescription: [0m[0;35mEnter a valid reminder or "." to end: [0m[31;1mNo available calendar to use[0m
[0m[0;35mTitle: [0m[0;35mLocation: [0m[0;35mWhen (? for help): [0m[0;35mDuration (human readable): [0m[0;35mDescription: [0m[0;35mEnter a valid reminder or "." to end: [0m[31;1mNo available calendar to use[0m
42 changes: 21 additions & 21 deletions tests/test_input_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
#
Expand All @@ -19,112 +19,112 @@ 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"
)

# 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'
)

# 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: <number><w|d|h|m> <popup|email|sms>\n")

# invalid reminder raises ValidationError
monkeypatch.setattr("builtins.input", lambda: "")
with pytest.raises(ValidationError):
validate_input(REMINDER) == ValidationError(
REMINDER(input()) == ValidationError(
"Format: <number><w|d|h|m> <popup|email|sms>\n")

0 comments on commit 093e6da

Please sign in to comment.