update documentation, fix some cases

This commit is contained in:
Daniel Tsvetkov 2021-05-11 13:23:17 +02:00
parent 6681f7dfd5
commit 8c68d50964
6 changed files with 133 additions and 106 deletions

2
.gitignore vendored
View File

@ -1,5 +1,5 @@
venv
src/tww/data/.cache.csv
tww/data/.cache.csv
__pycache__
.idea
tww.egg-info

View File

@ -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 `<datetime-like> to <timezone or location>`
* Supported `QUERY` types:
- Timezone translation `<datetime-like> in <timezone/location> to <destination timezone/location>` or `<datetime-like> to <timezone/location>` (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 <datetime-like> and <datetime-like>` or `(time) since <datetime-like` or `(time) until <datetime-like>`
- `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 <datetime-like>` or `
- `workdays since 2021-01-05`
- `work hours until Friday`
- Milliseconds since epoch: `(time/seconds) since epoch`
- Datetime to epoch: `<datetime-like> to epoch` or `(milli)seconds since <datetime-like>/epoch`
- `2021-01:01 to epoch`
- `milliseconds since epoch`
Few more notes:
- `<datetime-like>` 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`
`<datetime-like>` 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 <timezone or location>` 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
- `<timezone/location>` 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):

View File

@ -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
1 sofia 42.6975135 23.3241463
2 india 20.0 77.0
3 is 64.9841821 -18.1059013
4 u 42.6384261 12.674297
5 i 22.3511148 78.6677428
6 ut 39.4225192 -111.7143584
7 so 8.3676771 49.083416
8 sofi 42.6975135 23.3241463
9 z -48.5693327 -70.1606767
10 zu 47.1666667 8.5166664
11 zur 47.1666667 8.5166664
12 zuri 47.1666667 8.5166664
13 zuric 47.3666667 8.5500002
14 zurich 47.3666667 8.5500002
15 gl 77.6192349 -42.8125967
16 gla 37.08034 14.2306747
17 glas 37.08034 14.2306747
18 glasg 37.08034 14.2306747
19 glasgow 55.8333333 -4.25
20 L 33.7680065 66.2385139
21 lo -17.6394444 -71.3375015
22 L 33.7680065 66.2385139
23 lond 31.9513889 34.8952789
24 londo 51.5084153 -0.1255327
25 london 51.5084153 -0.1255327
26 london 51.5084153 -0.1255327
27 ne 6.9833333 171.6999969
28 n 64.5731537 11.52803643954819
29 new 6.9833333 171.6999969
30 new 6.9833333 171.6999969
31 new yo 6.9833333 171.6999969
32 new yor 6.9833333 171.6999969
33 new york 6.9833333 171.6999969
34 new york 6.9833333 171.6999969
35 mo 14.1194444 15.3133335
36 M -13.2687204 33.9301963
37 mou 14.1194444 15.3133335
38 mount 14.1194444 15.3133335
39 M -13.2687204 33.9301963
40 mounta 14.1194444 15.3133335
41 mountai 14.1194444 15.3133335
42 mountain 14.1194444 15.3133335
43 mountain vi 14.1194444 15.3133335
44 mountain view 14.1194444 15.3133335
45 in 6.9833333 171.6999969
46 ind 20.0 77.0
47 indi 20.0 77.0
48 local 37.6666667 -1.7
49 +02: 49.453285449999996 3.606899003594057
50 it 33.6366667 42.8224983
51 G 32.3293809 -83.1137366
52 D 10.0 -67.166667
53 dub -32.25 148.6166687
54 D 10.0 -67.166667
55 dubli 53.3330556 -6.248889
56 dublin 53.3330556 -6.248889
57 Du 25.0750095 55.18876088183319
58 dublin 53.3330556 -6.248889
59 S 35.7724185 127.79654346305617
60 sof 42.6975135 23.3241463
61 S 35.7724185 127.79654346305617
62 sofia 42.6975135 23.3241463
63 S 35.7724185 127.79654346305617
64 S 35.7724185 127.79654346305617
65 du -32.25 148.6166687
66 D 10.0 -67.166667
67 S 35.7724185 127.79654346305617
68 S 35.7724185 127.79654346305617
69 S 35.7724185 127.79654346305617
70 S 35.7724185 127.79654346305617
71 D 10.0 -67.166667
72 D 10.0 -67.166667
73 new y 6.9833333 171.6999969
74 S 35.7724185 127.79654346305617
75 S 35.7724185 127.79654346305617
76 D 10.0 -67.166667
77 dubl 53.3330556 -6.248889
78 D 10.0 -67.166667

View File

@ -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

View File

@ -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()

View File

@ -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)