diff options
| author | Gregor Kleen <gkleen@yggdrasil.li> | 2021-01-03 01:14:47 +0100 |
|---|---|---|
| committer | Gregor Kleen <gkleen@yggdrasil.li> | 2021-01-03 16:21:34 +0100 |
| commit | 392e6768f9f60c761b65f8774c1667ab8d00f230 (patch) | |
| tree | e84d0439a01402e3bd85a627c6594526f1d0a8ed /overlays/worktime | |
| parent | 4a3d2a8ddaf4e546df360656bc54b2947bdb890b (diff) | |
| download | nixos-392e6768f9f60c761b65f8774c1667ab8d00f230.tar nixos-392e6768f9f60c761b65f8774c1667ab8d00f230.tar.gz nixos-392e6768f9f60c761b65f8774c1667ab8d00f230.tar.bz2 nixos-392e6768f9f60c761b65f8774c1667ab8d00f230.tar.xz nixos-392e6768f9f60c761b65f8774c1667ab8d00f230.zip | |
gkleen@sif: systemd
Diffstat (limited to 'overlays/worktime')
| -rw-r--r-- | overlays/worktime/default.nix | 19 | ||||
| -rwxr-xr-x | overlays/worktime/worktime.py | 387 |
2 files changed, 406 insertions, 0 deletions
diff --git a/overlays/worktime/default.nix b/overlays/worktime/default.nix new file mode 100644 index 00000000..26e1dfed --- /dev/null +++ b/overlays/worktime/default.nix | |||
| @@ -0,0 +1,19 @@ | |||
| 1 | final: prev: { | ||
| 2 | worktime = prev.stdenv.mkDerivation rec { | ||
| 3 | name = "worktime"; | ||
| 4 | src = ./worktime.py; | ||
| 5 | |||
| 6 | phases = [ "buildPhase" "installPhase" ]; | ||
| 7 | |||
| 8 | python = prev.python37.withPackages (ps: with ps; [pyxdg dateutil uritools requests configparser]); | ||
| 9 | |||
| 10 | buildPhase = '' | ||
| 11 | substituteAll $src worktime | ||
| 12 | ''; | ||
| 13 | |||
| 14 | installPhase = '' | ||
| 15 | install -m 0755 -D -t $out/bin \ | ||
| 16 | worktime | ||
| 17 | ''; | ||
| 18 | }; | ||
| 19 | } | ||
diff --git a/overlays/worktime/worktime.py b/overlays/worktime/worktime.py new file mode 100755 index 00000000..9e514e65 --- /dev/null +++ b/overlays/worktime/worktime.py | |||
| @@ -0,0 +1,387 @@ | |||
| 1 | #!@python@/bin/python | ||
| 2 | |||
| 3 | import requests | ||
| 4 | from requests.exceptions import HTTPError | ||
| 5 | from requests.auth import HTTPBasicAuth | ||
| 6 | from datetime import * | ||
| 7 | from xdg import (BaseDirectory) | ||
| 8 | import configparser | ||
| 9 | from uritools import uricompose | ||
| 10 | |||
| 11 | from dateutil.easter import * | ||
| 12 | from dateutil.tz import * | ||
| 13 | from dateutil.parser import isoparse | ||
| 14 | |||
| 15 | from enum import Enum | ||
| 16 | |||
| 17 | from math import (copysign, ceil) | ||
| 18 | |||
| 19 | import calendar | ||
| 20 | |||
| 21 | import argparse | ||
| 22 | |||
| 23 | from copy import deepcopy | ||
| 24 | |||
| 25 | class TogglAPISection(Enum): | ||
| 26 | TOGGL = '/api/v8' | ||
| 27 | REPORTS = '/reports/api/v2' | ||
| 28 | |||
| 29 | class TogglAPIError(Exception): | ||
| 30 | def __init__(self, http_error, response): | ||
| 31 | self.http_error = http_error | ||
| 32 | self.response = response | ||
| 33 | |||
| 34 | def __str__(self): | ||
| 35 | if not self.http_error is None: | ||
| 36 | return str(self.http_error) | ||
| 37 | else: | ||
| 38 | return self.response.text | ||
| 39 | |||
| 40 | class TogglAPI(object): | ||
| 41 | def __init__(self, api_token, workspace_id): | ||
| 42 | self._api_token = api_token | ||
| 43 | self._workspace_id = workspace_id | ||
| 44 | |||
| 45 | def _make_url(self, api=TogglAPISection.TOGGL, section=['time_entries', 'current'], params={}): | ||
| 46 | if api is TogglAPISection.REPORTS: | ||
| 47 | params.update({'user_agent': 'worktime', 'workspace_id': self._workspace_id}) | ||
| 48 | |||
| 49 | api_path = api.value | ||
| 50 | section_path = '/'.join(section) | ||
| 51 | uri = uricompose(scheme='https', host='www.toggl.com', path=f"{api_path}/{section_path}", query=params) | ||
| 52 | |||
| 53 | return uri | ||
| 54 | |||
| 55 | def _query(self, url, method): | ||
| 56 | |||
| 57 | headers = {'content-type': 'application/json'} | ||
| 58 | response = None | ||
| 59 | |||
| 60 | if method == 'GET': | ||
| 61 | response = requests.get(url, headers=headers, auth=HTTPBasicAuth(self._api_token, 'api_token')) | ||
| 62 | elif method == 'POST': | ||
| 63 | response = requests.post(url, headers=headers, auth=HTTPBasicAuth(self._api_token, 'api_token')) | ||
| 64 | else: | ||
| 65 | raise ValueError(f"Undefined HTTP method “{method}”") | ||
| 66 | |||
| 67 | response.raise_for_status() | ||
| 68 | |||
| 69 | return response | ||
| 70 | |||
| 71 | def get_billable_hours(self, start_date, end_date=datetime.now(timezone.utc), rounding=False): | ||
| 72 | url = self._make_url(api = TogglAPISection.REPORTS, section = ['summary'], params={'since': start_date.astimezone(timezone.utc).isoformat(), 'until': end_date.astimezone(timezone.utc).isoformat(), 'rounding': rounding}) | ||
| 73 | r = self._query(url = url, method='GET') | ||
| 74 | if not r or not r.json(): | ||
| 75 | raise TogglAPIError(r) | ||
| 76 | |||
| 77 | return timedelta(milliseconds=r.json()['total_billable']) if r.json()['total_billable'] else timedelta(milliseconds=0) | ||
| 78 | |||
| 79 | def get_running_clock(self, now=datetime.now(timezone.utc)): | ||
| 80 | url = self._make_url(api = TogglAPISection.TOGGL, section = ['time_entries', 'current']) | ||
| 81 | r = self._query(url = url, method='GET') | ||
| 82 | |||
| 83 | if not r or not r.json(): | ||
| 84 | raise TogglAPIError(r) | ||
| 85 | |||
| 86 | if not r.json()['data'] or not r.json()['data']['billable']: | ||
| 87 | return None | ||
| 88 | |||
| 89 | start = isoparse(r.json()['data']['start']) | ||
| 90 | |||
| 91 | return now - start if start <= now else None | ||
| 92 | |||
| 93 | class Worktime(object): | ||
| 94 | time_worked = timedelta() | ||
| 95 | running_entry = None | ||
| 96 | now = datetime.now(tzlocal()) | ||
| 97 | time_pulled_forward = timedelta() | ||
| 98 | is_workday = False | ||
| 99 | include_running = True | ||
| 100 | time_to_work = None | ||
| 101 | force_day_to_work = True | ||
| 102 | |||
| 103 | def __init__(self, start_datetime=None, end_datetime=None, now=None, include_running=True, force_day_to_work=True, **kwargs): | ||
| 104 | self.include_running = include_running | ||
| 105 | self.force_day_to_work = force_day_to_work | ||
| 106 | |||
| 107 | if now: | ||
| 108 | self.now = now | ||
| 109 | |||
| 110 | config = configparser.ConfigParser() | ||
| 111 | config_dir = BaseDirectory.load_first_config('worktime') | ||
| 112 | config.read(f"{config_dir}/worktime.ini") | ||
| 113 | api = TogglAPI(api_token=config['TOGGL']['ApiToken'], workspace_id=config['TOGGL']['Workspace']) | ||
| 114 | date_format = config.get('WORKTIME', 'DateFormat', fallback='%Y-%m-%d') | ||
| 115 | |||
| 116 | start_date = start_datetime or datetime.strptime(config['WORKTIME']['StartDate'], date_format).replace(tzinfo=tzlocal()) | ||
| 117 | end_date = end_datetime or self.now | ||
| 118 | |||
| 119 | try: | ||
| 120 | with open(f"{config_dir}/reset", 'r') as reset: | ||
| 121 | for line in reset: | ||
| 122 | stripped_line = line.strip() | ||
| 123 | reset_date = datetime.strptime(stripped_line, date_format).replace(tzinfo=tzlocal()) | ||
| 124 | |||
| 125 | if reset_date > start_date and reset_date < end_date: | ||
| 126 | start_date = reset_date | ||
| 127 | except IOError as e: | ||
| 128 | if e.errno != 2: | ||
| 129 | raise e | ||
| 130 | |||
| 131 | |||
| 132 | hours_per_week = float(config.get('WORKTIME', 'HoursPerWeek', fallback=40)) | ||
| 133 | workdays = set([int(d.strip()) for d in config.get('WORKTIME', 'Workdays', fallback='1,2,3,4,5').split(',')]) | ||
| 134 | time_per_day = timedelta(hours = hours_per_week) / len(workdays) | ||
| 135 | |||
| 136 | holidays = dict() | ||
| 137 | |||
| 138 | for year in range(start_date.year, end_date.year + 1): | ||
| 139 | y_easter = datetime.combine(easter(year), time(), tzinfo=tzlocal()) | ||
| 140 | |||
| 141 | # Legal holidays in munich, bavaria | ||
| 142 | holidays[datetime(year, 1, 1, tzinfo=tzlocal()).date()] = time_per_day | ||
| 143 | holidays[datetime(year, 1, 6, tzinfo=tzlocal()).date()] = time_per_day | ||
| 144 | holidays[(y_easter+timedelta(days=-2)).date()] = time_per_day | ||
| 145 | holidays[(y_easter+timedelta(days=+1)).date()] = time_per_day | ||
| 146 | holidays[datetime(year, 5, 1, tzinfo=tzlocal()).date()] = time_per_day | ||
| 147 | holidays[(y_easter+timedelta(days=+39)).date()] = time_per_day | ||
| 148 | holidays[(y_easter+timedelta(days=+50)).date()] = time_per_day | ||
| 149 | holidays[(y_easter+timedelta(days=+60)).date()] = time_per_day | ||
| 150 | holidays[datetime(year, 8, 15, tzinfo=tzlocal()).date()] = time_per_day | ||
| 151 | holidays[datetime(year, 10, 3, tzinfo=tzlocal()).date()] = time_per_day | ||
| 152 | holidays[datetime(year, 11, 1, tzinfo=tzlocal()).date()] = time_per_day | ||
| 153 | holidays[datetime(year, 12, 25, tzinfo=tzlocal()).date()] = time_per_day | ||
| 154 | holidays[datetime(year, 12, 26, tzinfo=tzlocal()).date()] = time_per_day | ||
| 155 | |||
| 156 | try: | ||
| 157 | with open(f"{config_dir}/excused", 'r') as excused: | ||
| 158 | for line in excused: | ||
| 159 | stripped_line = line.strip() | ||
| 160 | if stripped_line: | ||
| 161 | splitLine = stripped_line.split(' ') | ||
| 162 | if len(splitLine) == 2: | ||
| 163 | [hours, date] = splitLine | ||
| 164 | day = datetime.strptime(date, date_format).replace(tzinfo=tzlocal()).date() | ||
| 165 | holidays[day] = timedelta(hours = float(hours)) | ||
| 166 | else: | ||
| 167 | holidays[datetime.strptime(stripped_line, date_format).replace(tzinfo=tzlocal()).date()] = time_per_day | ||
| 168 | except IOError as e: | ||
| 169 | if e.errno != 2: | ||
| 170 | raise e | ||
| 171 | |||
| 172 | pull_forward = dict() | ||
| 173 | |||
| 174 | start_day = start_date.date() | ||
| 175 | end_day = end_date.date() | ||
| 176 | |||
| 177 | try: | ||
| 178 | with open(f"{config_dir}/pull-forward", 'r') as excused: | ||
| 179 | for line in excused: | ||
| 180 | stripped_line = line.strip() | ||
| 181 | if stripped_line: | ||
| 182 | [hours, date] = stripped_line.split(' ') | ||
| 183 | constr = date.split(',') | ||
| 184 | for d in [start_day + timedelta(days = x) for x in range(0, (end_day - start_day).days + 1 + int(timedelta(hours = float(hours)).total_seconds() / 60 * (7 / len(workdays)) * 2))]: | ||
| 185 | for c in constr: | ||
| 186 | if c in calendar.day_abbr: | ||
| 187 | if not d.strftime('%a') == c: break | ||
| 188 | elif "--" in c: | ||
| 189 | [fromDay,toDay] = c.split('--') | ||
| 190 | if fromDay != "": | ||
| 191 | fromDay = datetime.strptime(fromDay, date_format).replace(tzinfo=tzlocal()).date() | ||
| 192 | if not fromDay <= d: break | ||
| 193 | if toDay != "": | ||
| 194 | toDay = datetime.strptime(toDay, date_format).replace(tzinfo=tzlocal()).date() | ||
| 195 | if not d <= toDay: break | ||
| 196 | else: | ||
| 197 | if not d == datetime.strptime(c, date_format).replace(tzinfo=tzlocal()).date(): break | ||
| 198 | else: | ||
| 199 | if d >= end_date.date(): | ||
| 200 | pull_forward[d] = min(timedelta(hours = float(hours)), time_per_day - (holidays[d] if d in holidays else timedelta())) | ||
| 201 | except IOError as e: | ||
| 202 | if e.errno != 2: | ||
| 203 | raise e | ||
| 204 | |||
| 205 | days_to_work = dict() | ||
| 206 | |||
| 207 | if pull_forward: | ||
| 208 | end_day = max(end_day, max(list(pull_forward))) | ||
| 209 | |||
| 210 | for day in [start_day + timedelta(days = x) for x in range(0, (end_day - start_day).days + 1)]: | ||
| 211 | if day.isoweekday() in workdays: | ||
| 212 | time_to_work = time_per_day | ||
| 213 | if day in holidays.keys(): | ||
| 214 | time_to_work -= holidays[day] | ||
| 215 | if time_to_work > timedelta(): | ||
| 216 | days_to_work[day] = time_to_work | ||
| 217 | |||
| 218 | extra_days_to_work = dict() | ||
| 219 | |||
| 220 | try: | ||
| 221 | with open(f"{config_dir}/days-to-work", 'r') as extra_days_to_work_file: | ||
| 222 | for line in extra_days_to_work_file: | ||
| 223 | stripped_line = line.strip() | ||
| 224 | if stripped_line: | ||
| 225 | splitLine = stripped_line.split(' ') | ||
| 226 | if len(splitLine) == 2: | ||
| 227 | [hours, date] = splitLine | ||
| 228 | day = datetime.strptime(date, date_format).replace(tzinfo=tzlocal()).date() | ||
| 229 | extra_days_to_work[day] = timedelta(hours = float(hours)) | ||
| 230 | else: | ||
| 231 | extra_days_to_work[datetime.strptime(stripped_line, date_format).replace(tzinfo=tzlocal()).date()] = time_per_day | ||
| 232 | except IOError as e: | ||
| 233 | if e.errno != 2: | ||
| 234 | raise e | ||
| 235 | |||
| 236 | |||
| 237 | self.is_workday = self.now.date() in days_to_work or self.now.date() in extra_days_to_work | ||
| 238 | |||
| 239 | self.time_worked = timedelta() | ||
| 240 | |||
| 241 | if self.include_running: | ||
| 242 | self.running_entry = api.get_running_clock(self.now) | ||
| 243 | |||
| 244 | if self.running_entry: | ||
| 245 | self.time_worked += self.running_entry | ||
| 246 | |||
| 247 | if self.running_entry and self.include_running and self.force_day_to_work and not (self.now.date() in days_to_work or self.now.date() in extra_days_to_work): | ||
| 248 | extra_days_to_work[self.now.date()] = timedelta() | ||
| 249 | |||
| 250 | self.time_to_work = sum([days_to_work[day] for day in days_to_work.keys() if day <= end_date.date()], timedelta()) | ||
| 251 | for day in [d for d in list(pull_forward) if d > end_date.date()]: | ||
| 252 | days_forward = set([d for d in days_to_work.keys() if d >= end_date.date() and d < day and (not d in pull_forward or d == end_date.date())]) | ||
| 253 | extra_days_forward = set([d for d in extra_days_to_work.keys() if d >= end_date.date() and d < day and (not d in pull_forward or d == end_date.date())]) | ||
| 254 | days_forward = days_forward.union(extra_days_forward) | ||
| 255 | |||
| 256 | extra_day_time_left = timedelta() | ||
| 257 | for extra_day in extra_days_forward: | ||
| 258 | day_time = max(timedelta(), time_per_day - extra_days_to_work[extra_day]) | ||
| 259 | extra_day_time_left += day_time | ||
| 260 | extra_day_time = min(extra_day_time_left, pull_forward[day]) | ||
| 261 | time_forward = pull_forward[day] - extra_day_time | ||
| 262 | if extra_day_time_left > timedelta(): | ||
| 263 | for extra_day in extra_days_forward: | ||
| 264 | day_time = max(timedelta(), time_per_day - extra_days_to_work[extra_day]) | ||
| 265 | extra_days_to_work[extra_day] += extra_day_time * (day_time / extra_day_time_left) | ||
| 266 | |||
| 267 | hours_per_day_forward = time_forward / len(days_forward) if len(days_forward) > 0 else timedelta() | ||
| 268 | days_forward.discard(end_date.date()) | ||
| 269 | |||
| 270 | self.time_pulled_forward += time_forward - hours_per_day_forward * len(days_forward) | ||
| 271 | |||
| 272 | if end_date.date() in extra_days_to_work: | ||
| 273 | self.time_pulled_forward += extra_days_to_work[end_date.date()] | ||
| 274 | |||
| 275 | self.time_to_work += self.time_pulled_forward | ||
| 276 | |||
| 277 | self.time_worked += api.get_billable_hours(start_date, self.now, rounding = config.getboolean('WORKTIME', 'rounding', fallback=True)) | ||
| 278 | |||
| 279 | def worktime(**args): | ||
| 280 | worktime = Worktime(**args) | ||
| 281 | |||
| 282 | def format_worktime(worktime): | ||
| 283 | def difference_string(difference): | ||
| 284 | total_minutes_difference = round(difference / timedelta(minutes = 1)) | ||
| 285 | (hours_difference, minutes_difference) = divmod(abs(total_minutes_difference), 60) | ||
| 286 | sign = '' if total_minutes_difference >= 0 else '-' | ||
| 287 | |||
| 288 | difference_string = f"{sign}" | ||
| 289 | if hours_difference != 0: | ||
| 290 | difference_string += f"{hours_difference}h" | ||
| 291 | if hours_difference == 0 or minutes_difference != 0: | ||
| 292 | difference_string += f"{minutes_difference}m" | ||
| 293 | |||
| 294 | return difference_string | ||
| 295 | |||
| 296 | difference = worktime.time_to_work - worktime.time_worked | ||
| 297 | total_minutes_difference = 5 * ceil(difference / timedelta(minutes = 5)) | ||
| 298 | |||
| 299 | if worktime.running_entry and abs(difference) < timedelta(days = 1) and (total_minutes_difference > 0 or abs(worktime.running_entry) >= abs(difference)) : | ||
| 300 | clockout_time = worktime.now + difference | ||
| 301 | clockout_time += (5 - clockout_time.minute % 5) * timedelta(minutes = 1) | ||
| 302 | clockout_time = clockout_time.replace(second = 0, microsecond = 0) | ||
| 303 | |||
| 304 | if total_minutes_difference >= 0: | ||
| 305 | difference_string = difference_string(total_minutes_difference * timedelta(minutes = 1)) | ||
| 306 | return "{difference_string}/{clockout_time}".format(difference_string = difference_string, clockout_time = clockout_time.strftime("%H:%M")) | ||
| 307 | else: | ||
| 308 | difference_string = difference_string(abs(total_minutes_difference) * timedelta(minutes = 1)) | ||
| 309 | return "{clockout_time}/{difference_string}".format(difference_string = difference_string, clockout_time = clockout_time.strftime("%H:%M")) | ||
| 310 | else: | ||
| 311 | if worktime.running_entry: | ||
| 312 | difference_string = difference_string(abs(total_minutes_difference) * timedelta(minutes = 1)) | ||
| 313 | indicator = '↓' if total_minutes_difference >= 0 else '↑' # '\u25b6' | ||
| 314 | |||
| 315 | return f"{indicator}{difference_string}" | ||
| 316 | else: | ||
| 317 | difference_string = difference_string(total_minutes_difference * timedelta(minutes = 1)) | ||
| 318 | if worktime.is_workday: | ||
| 319 | return difference_string | ||
| 320 | else: | ||
| 321 | return f"({difference_string})" | ||
| 322 | |||
| 323 | if worktime.time_pulled_forward >= timedelta(minutes = 15): | ||
| 324 | worktime_no_pulled_forward = deepcopy(worktime) | ||
| 325 | worktime_no_pulled_forward.time_to_work -= worktime_no_pulled_forward.time_pulled_forward | ||
| 326 | worktime_no_pulled_forward.time_pulled_forward = timedelta() | ||
| 327 | |||
| 328 | difference_string = format_worktime(worktime) | ||
| 329 | difference_string_no_pulled_forward = format_worktime(worktime_no_pulled_forward) | ||
| 330 | |||
| 331 | print(f"{difference_string_no_pulled_forward}…{difference_string}") | ||
| 332 | else: | ||
| 333 | print(format_worktime(worktime)) | ||
| 334 | |||
| 335 | def time_worked(now, **args): | ||
| 336 | then = now.replace(hour = 0, minute = 0, second = 0, microsecond = 0) | ||
| 337 | if now.time() == time(): | ||
| 338 | now = now + timedelta(days = 1) | ||
| 339 | |||
| 340 | then = Worktime(**dict(args, now = then)) | ||
| 341 | now = Worktime(**dict(args, now = now)) | ||
| 342 | |||
| 343 | worked = now.time_worked - then.time_worked | ||
| 344 | |||
| 345 | if args['do_round']: | ||
| 346 | total_minutes_difference = 5 * ceil(worked / timedelta(minutes = 5)) | ||
| 347 | (hours_difference, minutes_difference) = divmod(abs(total_minutes_difference), 60) | ||
| 348 | sign = '' if total_minutes_difference >= 0 else '-' | ||
| 349 | |||
| 350 | difference_string = f"{sign}" | ||
| 351 | if hours_difference != 0: | ||
| 352 | difference_string += f"{hours_difference}h" | ||
| 353 | if hours_difference == 0 or minutes_difference != 0: | ||
| 354 | difference_string += f"{minutes_difference}m" | ||
| 355 | |||
| 356 | print(difference_string) | ||
| 357 | else: | ||
| 358 | print(worked) | ||
| 359 | |||
| 360 | def diff(now, **args): | ||
| 361 | now = now.replace(hour = 0, minute = 0, second = 0, microsecond = 0) | ||
| 362 | then = now - timedelta.resolution | ||
| 363 | |||
| 364 | then = Worktime(**dict(args, now = then, include_running = False)) | ||
| 365 | now = Worktime(**dict(args, now = now, include_running = False)) | ||
| 366 | |||
| 367 | print(now.time_to_work - then.time_to_work) | ||
| 368 | |||
| 369 | |||
| 370 | def main(): | ||
| 371 | parser = argparse.ArgumentParser(prog = "worktime", description = 'Track worktime using toggl API') | ||
| 372 | parser.add_argument('--time', dest = 'now', metavar = 'TIME', type = lambda s: datetime.fromisoformat(s).replace(tzinfo=tzlocal()), help = 'Time to calculate status for (default: current time)', default = datetime.now(tzlocal())) | ||
| 373 | parser.add_argument('--no-running', dest = 'include_running', action = 'store_false') | ||
| 374 | parser.add_argument('--no-force-day-to-work', dest = 'force_day_to_work', action = 'store_false') | ||
| 375 | subparsers = parser.add_subparsers(help = 'Subcommands') | ||
| 376 | parser.set_defaults(cmd = worktime) | ||
| 377 | time_worked_parser = subparsers.add_parser('time_worked', aliases = ['time', 'worked', 'today']) | ||
| 378 | time_worked_parser.add_argument('--no-round', dest = 'do_round', action = 'store_false') | ||
| 379 | time_worked_parser.set_defaults(cmd = time_worked) | ||
| 380 | diff_parser = subparsers.add_parser('diff') | ||
| 381 | diff_parser.set_defaults(cmd = diff) | ||
| 382 | args = parser.parse_args() | ||
| 383 | |||
| 384 | args.cmd(**vars(args)) | ||
| 385 | |||
| 386 | if __name__ == "__main__": | ||
| 387 | sys.exit(main()) | ||
