diff --git a/.gitignore b/.gitignore index ca6e2e4..6eafe41 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ venv -src/tww/data/.cache.csv +tww/data/.cache.csv __pycache__ .idea tww.egg-info \ No newline at end of file diff --git a/README.md b/README.md index 7807038..f8cd111 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,40 @@ Find time now, in the past or future in any timezone or location. ## Usage ``` -python tz.py QUERY [--format="%Y-%m-%d %H:%M:%S%z"] [--debug] +python tww QUERY [--debug] [--full] ``` -* `QUERY` - is of the form ` to ` +* Supported `QUERY` types: + - Timezone translation ` in to ` or ` to ` (assumes datetime is `local`) + - `04:26 in japan to local` + - `03:14 in local to IST` + - `15:20 to America/New_York` + - `2021-12-25 12:00 in Brazil` + - Time difference: `(time) between and ` or `(time) since ` + - `time between 2012-03-14 and 2012-04-26` + - `time since 09:00` + - `time until end of workday` + - (Approximate) workdays calculation (assumes monday-friday are work days - ignores public/local holidays (for now)): `work days/hours since/until ` or ` + - `workdays since 2021-01-05` + - `work hours until Friday` + - Milliseconds since epoch: `(time/seconds) since epoch` + - Datetime to epoch: ` to epoch` or `(milli)seconds since /epoch` + - `2021-01:01 to epoch` + - `milliseconds since epoch` + + +Few more notes: +- `` is any time or time-like string - or example: + - `2019-04-26 3:14`, `06:42` `27 January 1992`, + - some human readable like `now`, `in 3 hours`, `7 minutes ago` and many others. + - See [dateparser](https://pypi.org/project/dateparser/) for more. + - custom date-times (like `christmas`, `new years`, `end of workday`) defined in `data/custom_dt.csv` -`` is any time or time-like string - or example `2019-04-26 3:14`, `06:42`, timezones `15:10 cet` but also some human readable like `now`, `in 3 hours`, `7 minutes ago` and many others. See [dateparser](https://pypi.org/project/dateparser/) for more. - -`to ` is optional. It has to have the word `to` which specifies that timezone or location follows. It is either: - - timezone (tried first) to which the date should be translated or +- `` is either: + - timezone (tried first) - it can be: + - [tz database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (like `Europe/Sofia`), + - [UTC time offset](https://en.wikipedia.org/wiki/List_of_UTC_time_offsets) (with colon `:` or not like `+02:00` or `+0530`), or + - [abbreviation](https://en.wikipedia.org/wiki/List_of_time_zone_abbreviations) (like `UTC`, `CET`, `PST` - however please note that these are not unique and resolution might wrong) - a location. Uses a local database of files of countries and cities. It then tries to fuzzymatch the query using [fuzzywuzzy](https://github.com/seatgeek/fuzzywuzzy). In case it can't find the country or city, it uses [geopy](https://geopy.readthedocs.io/en/stable/) for location resolution. Finally it uses [timezonefinder](https://pypi.org/project/timezonefinder/) for timezone resolution. * `--format` is the format of the time to be displayed. See supported [datetime formats](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) @@ -27,10 +52,10 @@ pip install -r requirements.txt You could alias the whole command to `tww` for faster typing, e.g. in your `.bashrc`: ``` -alias tww="~/workspace/tz/venv/bin/python ~/workspace/tz/tz.py" +alias tww="~/workspace/tww/venv/bin/python ~/workspace/tww/main.py" ``` -## Examples +## More Examples Time now (in this timezone): diff --git a/tww/data/.cache.csv b/tww/data/.cache.csv deleted file mode 100644 index 1ddfc55..0000000 --- a/tww/data/.cache.csv +++ /dev/null @@ -1,78 +0,0 @@ -sofia,42.6975135,23.3241463 -india,20.0,77.0 -is,64.9841821,-18.1059013 -u,42.6384261,12.674297 -i,22.3511148,78.6677428 -ut,39.4225192,-111.7143584 -so,8.3676771,49.083416 -sofi,42.6975135,23.3241463 -z,-48.5693327,-70.1606767 -zu,47.1666667,8.5166664 -zur,47.1666667,8.5166664 -zuri,47.1666667,8.5166664 -zuric,47.3666667,8.5500002 -zurich,47.3666667,8.5500002 -gl,77.6192349,-42.8125967 -gla,37.08034,14.2306747 -glas,37.08034,14.2306747 -glasg,37.08034,14.2306747 -glasgow,55.8333333,-4.25 -L,33.7680065,66.2385139 -lo,-17.6394444,-71.3375015 -L,33.7680065,66.2385139 -lond,31.9513889,34.8952789 -londo,51.5084153,-0.1255327 -london,51.5084153,-0.1255327 -london ,51.5084153,-0.1255327 -ne,6.9833333,171.6999969 -n,64.5731537,11.52803643954819 -new,6.9833333,171.6999969 -new ,6.9833333,171.6999969 -new yo,6.9833333,171.6999969 -new yor,6.9833333,171.6999969 -new york,6.9833333,171.6999969 -new york ,6.9833333,171.6999969 -mo,14.1194444,15.3133335 -M,-13.2687204,33.9301963 -mou,14.1194444,15.3133335 -mount,14.1194444,15.3133335 -M,-13.2687204,33.9301963 -mounta,14.1194444,15.3133335 -mountai,14.1194444,15.3133335 -mountain ,14.1194444,15.3133335 -mountain vi,14.1194444,15.3133335 -mountain view,14.1194444,15.3133335 -in,6.9833333,171.6999969 -ind,20.0,77.0 -indi,20.0,77.0 -local,37.6666667,-1.7 -+02:,49.453285449999996,3.606899003594057 -it,33.6366667,42.8224983 -G,32.3293809,-83.1137366 -D,10.0,-67.166667 -dub,-32.25,148.6166687 -D,10.0,-67.166667 -dubli,53.3330556,-6.248889 -dublin,53.3330556,-6.248889 -Du,25.0750095,55.18876088183319 -dublin ,53.3330556,-6.248889 -S,35.7724185,127.79654346305617 -sof,42.6975135,23.3241463 -S,35.7724185,127.79654346305617 -sofia ,42.6975135,23.3241463 -S,35.7724185,127.79654346305617 -S,35.7724185,127.79654346305617 -du,-32.25,148.6166687 -D,10.0,-67.166667 -S,35.7724185,127.79654346305617 -S,35.7724185,127.79654346305617 -S,35.7724185,127.79654346305617 -S,35.7724185,127.79654346305617 -D,10.0,-67.166667 -D,10.0,-67.166667 -new y,6.9833333,171.6999969 -S,35.7724185,127.79654346305617 -S,35.7724185,127.79654346305617 -D,10.0,-67.166667 -dubl,53.3330556,-6.248889 -D,10.0,-67.166667 diff --git a/tww/lib.py b/tww/lib.py index cbde44c..6873835 100644 --- a/tww/lib.py +++ b/tww/lib.py @@ -206,7 +206,6 @@ def resolve_location_local(query): return "" ratio, location = result logger.debug("Location result ({}): {}".format(-ratio, location)) - write_to_cache(query, location) return location @@ -757,20 +756,60 @@ def td_remainders(td): minutes, seconds = divmod(abs(int(seconds)), 60) hours, minutes = divmod(abs(int(minutes)), 60) days, hours = divmod(abs(int(hours)), 24) - months, days = divmod(abs(int(days)), 30.42) + total_days = days + months, days = divmod(abs(int(days)), 30) years, months = divmod(abs(int(months)), 12) - years, months, days, hours, minutes, seconds = map(int, (years, months, days, hours, minutes, seconds)) - years, months, days, hours, minutes, seconds = map(abs, (years, months, days, hours, minutes, seconds)) + + total_weeks, _ = divmod(abs(int(total_days)), 7) + total_months, _ = divmod(abs(int(total_days)), 30) + + days_years = total_days - 365 * years + days_weeks = total_days - 7 * total_weeks + days_months = total_days - 30 * total_months + weeks_months, _ = divmod(abs(int(total_days - 30 * total_months)), 7) + days_weeks_months = total_days - 30 * total_months - 7 * weeks_months + + years, months, days, year_days, hours, minutes, seconds = map(int, (years, months, days, days_years, hours, minutes, seconds)) + years, months, days, year_days, hours, minutes, seconds = map(abs, (years, months, days, year_days, hours, minutes, seconds)) return dict( seconds=seconds, minutes=minutes, hours=hours, days=days, + weeks_months=weeks_months, # how many weeks in addition to "months" + days_years=days_years, # how many days in addition to "years" + days_months=days_months, # how many days in addition to "months" + days_weeks=days_weeks, # how many days in addition to "weeks" + days_weeks_months=days_weeks_months, # how many days in addition to "months" and "weeks" months=months, years=years, ) +def td_machine(remainders): + years, days_years, hours, minutes, seconds = map(lambda x: remainders[x], + ["years", "days_years", "hours", "minutes", "seconds"]) + return "{} years, {} days, {:02d}:{:02d}:{:02d}".format(years, days_years, hours, minutes, seconds) + + +def td_human(remainders): + rv = "" + for x in ["years", "months", "days", "hours", "minutes", "seconds"]: + period = remainders[x] + if period > 0: + rv += "{} {}, ".format(period, x[:-1] if period == 1 else x) + return rv[:-2] + + +def td_human_months_weeks(remainders): + rv = "" + for x in ["years", "months", "weeks_months", "days_weeks_months", "hours", "minutes", "seconds"]: + period = remainders[x] + if period > 0: + rv += "{} {}, ".format(period, x.split('_')[0][:-1] if period == 1 else x.split('_')[0]) + return rv[:-2] + + def td_totals(td): seconds = td.total_seconds() minutes = seconds / 60 diff --git a/tww/test_tww.py b/tww/test_tww.py index 2e7f266..b8a8fee 100644 --- a/tww/test_tww.py +++ b/tww/test_tww.py @@ -3,7 +3,7 @@ import unittest from freezegun import freeze_time -from tww import query_to_format_result, setup_logging_level +from tww.tokenizer import main, setup_logging_level def get_local_hours_offset(): @@ -22,7 +22,7 @@ UTC = -get_local_hours_offset() # LocalTimezone, Query, Result INTEGRATION_TESTS = [ - (UTC, "now", "2015-03-14 09:26:53"), + (UTC, "now", ["2015-03-14 09:26:53"]), # in two hours -> 11:26 | to tokyo +9 -> 20:26 (0, "in two hours to asia/tokyo", "2015-03-14 20:26:53"), # now in UTC +2hrs -> 11:26 @@ -38,12 +38,13 @@ class IntegrationTests(unittest.TestCase): for test in INTEGRATION_TESTS: local_tz = test[0] query = test[1].split(" ") + args = type("false_args", (), {"query": query, "locale": None, "full": False, "handlers": False}) result = test[2] freezer = freeze_time(FROZEN_TIME, tz_offset=local_tz) freezer.start() - self.assertEqual(query_to_format_result(query), result) + self.assertEqual(main(args), result) freezer.stop() diff --git a/tww/tokenizer.py b/tww/tokenizer.py index 0e81cf6..1c1ef1e 100644 --- a/tww/tokenizer.py +++ b/tww/tokenizer.py @@ -10,7 +10,7 @@ from scalpl import Cut from tww.localization import setlocale, resolve_locale from tww.lib import ISO_FORMAT, time_to_emoji, time_ago, workday_diff, workhours_diff, td_remainders, td_totals, \ - td_iso8601, ISO_Z_FORMAT + td_iso8601, ISO_Z_FORMAT, td_human, td_machine, tzinfo_from_offset, td_human_months_weeks from tww.lib import resolve_timezone, dateparser_parse_dt, get_utcnow, get_s_since_epoch, get_ms_since_epoch, \ dt_tz_translation, get_local_now, query_to_format_result from tww.common import logger @@ -35,6 +35,8 @@ r_workhours_since = re.compile('(?:workhours|work hours)\s*since\s*(.*)', flags= r_workhours_until = re.compile('(?:workhours|work hours)\s*until\s*(.*)', flags=re.IGNORECASE) r_workhours_between = re.compile('(?:workhours|work hours)\s*between\s*(.*)\s*and\s*(.*)', flags=re.IGNORECASE) r_timezone_translation = re.compile('(.*)?\s(?:in|to)\s(.*)', flags=re.IGNORECASE) +r_timezone_translation_in_to = re.compile('(.*)(?:in)\s(.*)\s(?:to)\s(.*)', flags=re.IGNORECASE) +r_hour_minute_timezone = re.compile('((?:[0[0-9]|1[0-9]|2[0-3]):[0-5][0-9])?\s(.*)', flags=re.IGNORECASE) r_timezone = re.compile('(.*)\s(?:timezone|timezones|tz)', flags=re.IGNORECASE) r_timezone_2 = re.compile('(?:timezone in|timezones in|tz in|timezone|timezones|tz)\s(.*)?', flags=re.IGNORECASE) @@ -91,23 +93,45 @@ def handler_workhours_diff(start_dt, end_dt) -> dict: diff=td_pretty(diff)) -def handler_workhours_since_until(start_dt_s: str) -> dict: +def handler_workhours_since_until(start_dt_s: str): return handler_workhours_diff(dateparser_parse_dt(start_dt_s), get_local_now()) -def handler_timezone_translation(dt_s: str, timezone_like_s: str) -> dict: +def timezone_mangle(dt_s: str, timezone_like_s: str) -> dict: + logger.debug("Timezone translation between {} | {}".format(dt_s, timezone_like_s)) if dt_s.lower().strip() == "time": dt_s = "now" src_dt = dateparser_parse_dt(dt_s) + logger.debug("Source time: {}".format(src_dt)) tz = resolve_timezone(timezone_like_s) + logger.debug("Destination timezone: {}".format(tz)) if not tz: tz, dst_dt = {}, src_dt else: offset = tz.get('tz_offset') - dst_dt = dt_tz_translation(src_dt, offset) + return src_dt, offset, tz + + +def handler_timezone_creation(dt_s: str, timezone_like_s: str): + src_dt, offset, tz = timezone_mangle(dt_s, timezone_like_s) + tzinfo = tzinfo_from_offset(offset) + dst_dt = tzinfo[0].localize(src_dt.replace(tzinfo=None)) return src_dt, dst_dt, tz +def handler_timezone_translation(dt_s: str, timezone_like_s: str): + src_dt, offset, tz = timezone_mangle(dt_s, timezone_like_s) + dst_dt = dt_tz_translation(src_dt, offset) + return src_dt, dst_dt, tz + + +def handler_timezone_translation_in_to(dt_s: str, timezone_like_s: str, timezone_like_d: str): + _, src_dt, _ = handler_timezone_creation(dt_s, timezone_like_s) + _, dst_offset, dst_tz = timezone_mangle(dt_s, timezone_like_d) + dst_dt = dt_tz_translation(src_dt, dst_offset) + return src_dt, dst_dt, dst_tz + + def handler_generic_parser(dt_s: str) -> datetime: return dateparser_parse_dt(dt_s) @@ -132,7 +156,7 @@ h_tz_offset = 'tz->tz_offset' h_time_in = 'dt->hh:mm' h_translation = 'dt->iso8601_full' h_default_dt = 'dt->iso8601_full' -h_default_td = 'timedelta->diff->duration_iso8601' +h_default_td = 'timedelta->diff->duration_human' regex_handlers = [ (r_time_in_epoch_s_now, handler_time_now_local, QUERY_TYPE_DT, h_unix_s), @@ -144,6 +168,7 @@ regex_handlers = [ (r_time_in_epoch_ms2, handler_time, QUERY_TYPE_DT, h_unix_ms), (r_time_in_epoch_ms3, handler_time, QUERY_TYPE_DT, h_unix_ms), (r_timezone_translation, handler_timezone_translation, QUERY_TYPE_DT_TR, h_translation), + (r_timezone_translation_in_to, handler_timezone_translation_in_to, QUERY_TYPE_DT_TR, h_translation), (r_time_since, handler_time_since_until, QUERY_TYPE_TD, h_default_td), (r_time_until, handler_time_since_until, QUERY_TYPE_TD, h_default_td), (r_time_between, handler_time_diff, QUERY_TYPE_TD, h_default_td), @@ -156,6 +181,7 @@ regex_handlers = [ (r_time_in, handler_time_in_parser, QUERY_TYPE_DT, h_time_in), (r_timezone, handler_timezone, QUERY_TYPE_TZ, h_tz_offset), (r_timezone_2, handler_timezone, QUERY_TYPE_TZ, h_tz_offset), + (r_hour_minute_timezone, handler_timezone_creation, QUERY_TYPE_DT_TR, h_translation), (r_generic, handler_generic_parser, QUERY_TYPE_DT, h_default_dt), ] @@ -173,13 +199,18 @@ def try_regex(r, s): def tokenize(s): solutions = [] for r, h, t, hi in regex_handlers: + logger.debug("Trying regex: {}".format(r)) g = try_regex(r, s) if g is not None: try: + logger.debug("Matched regex: {}".format(r)) + logger.debug("Running handler: {} | query_type: {} | output_type: {}".format(h.__name__, t, hi)) result = h(*g) except Exception as e: + logger.debug("Exception from handler: {} -> {}".format(h.__name__, e)) continue if result is not None: + logger.debug("Matched regex: {}".format(r)) solutions.append((h.__name__, result, t, hi)) return solutions @@ -191,6 +222,7 @@ def pretty_print_dict(obj): def show_magic_results(obj, args): + rv = [] for solution in obj['solutions']: entry_proxy = Cut(solution, sep='->') highlight_entry = solution["highlight"] @@ -199,10 +231,12 @@ def show_magic_results(obj, args): except Exception as e: continue if args.handlers: - print("{} -> {}".format(solution['handler'], highlight_result)) + to_print = "{} -> {}".format(solution['handler'], highlight_result) else: - print(highlight_result) - + to_print = highlight_result + rv.append(to_print) + print(to_print) + return rv def dt_pretty(dt): rv = {} @@ -212,7 +246,6 @@ def dt_pretty(dt): from_tz_offset=dt.strftime('%z')).strftime(ISO_Z_FORMAT) rv["iso8601_date"] = dt.strftime('%Y-%m-%d') rv["iso8601_time"] = dt.strftime('%H:%M:%S') - rv["locale_dt"] = dt.strftime("%c") rv["day_of_week_number"] = dt.strftime("%w") rv["locale"] = custom_locale with setlocale(locale.LC_TIME, custom_locale.get("lc_time")): @@ -222,6 +255,7 @@ def dt_pretty(dt): rv["locale_day_of_week"] = dt.strftime("%A") rv["locale_date"] = dt.strftime("%x") rv["locale_time"] = dt.strftime("%X") + rv["locale_dt"] = dt.strftime("%c") rv["tz_offset"] = dt.strftime("%z") rv["hh:mm"] = dt.strftime("%H:%M") rv["emoji_time"] = time_to_emoji(dt) @@ -231,13 +265,17 @@ def dt_pretty(dt): def td_pretty(td): + remainders = td_remainders(td) rv = { "sign": '-' if td.days > 0 else '+', "in_the": 'future' if td.days < 0 else 'past', "time_ago": time_ago(td), "duration_iso8601": td_iso8601(td), + "duration_machine": td_machine(remainders), + "duration_human": td_human(remainders), + "duration_human_months_weeks": td_human_months_weeks(remainders), "totals": td_totals(td), - "remainders": td_remainders(td), + "remainders": remainders, } return rv @@ -331,9 +369,11 @@ def setup_logging_level(debug=False): def main(args): global custom_locale custom_locale = resolve_locale(args.locale) + if custom_locale: + logger.debug("Locale understood as: {}".format(custom_locale)) query = ' '.join(args.query) - # query = "tz in sofia" result = resolve_query(query) if args.full: pretty_print_dict(result) - show_magic_results(result, args) + return show_magic_results(result, args) +