diff options
Diffstat (limited to 'overlays/worktime')
| -rw-r--r-- | overlays/worktime/poetry.lock | 32 | ||||
| -rw-r--r-- | overlays/worktime/pyproject.toml | 1 | ||||
| -rwxr-xr-x | overlays/worktime/worktime/__main__.py | 123 |
3 files changed, 102 insertions, 54 deletions
diff --git a/overlays/worktime/poetry.lock b/overlays/worktime/poetry.lock index eab1d070..54182b09 100644 --- a/overlays/worktime/poetry.lock +++ b/overlays/worktime/poetry.lock | |||
| @@ -1,10 +1,9 @@ | |||
| 1 | # This file is automatically @generated by Poetry and should not be changed by hand. | 1 | # This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. |
| 2 | 2 | ||
| 3 | [[package]] | 3 | [[package]] |
| 4 | name = "backoff" | 4 | name = "backoff" |
| 5 | version = "2.2.1" | 5 | version = "2.2.1" |
| 6 | description = "Function decoration for backoff and retry" | 6 | description = "Function decoration for backoff and retry" |
| 7 | category = "main" | ||
| 8 | optional = false | 7 | optional = false |
| 9 | python-versions = ">=3.7,<4.0" | 8 | python-versions = ">=3.7,<4.0" |
| 10 | files = [ | 9 | files = [ |
| @@ -16,7 +15,6 @@ files = [ | |||
| 16 | name = "certifi" | 15 | name = "certifi" |
| 17 | version = "2022.12.7" | 16 | version = "2022.12.7" |
| 18 | description = "Python package for providing Mozilla's CA Bundle." | 17 | description = "Python package for providing Mozilla's CA Bundle." |
| 19 | category = "main" | ||
| 20 | optional = false | 18 | optional = false |
| 21 | python-versions = ">=3.6" | 19 | python-versions = ">=3.6" |
| 22 | files = [ | 20 | files = [ |
| @@ -28,7 +26,6 @@ files = [ | |||
| 28 | name = "charset-normalizer" | 26 | name = "charset-normalizer" |
| 29 | version = "3.1.0" | 27 | version = "3.1.0" |
| 30 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." | 28 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." |
| 31 | category = "main" | ||
| 32 | optional = false | 29 | optional = false |
| 33 | python-versions = ">=3.7.0" | 30 | python-versions = ">=3.7.0" |
| 34 | files = [ | 31 | files = [ |
| @@ -113,7 +110,6 @@ files = [ | |||
| 113 | name = "idna" | 110 | name = "idna" |
| 114 | version = "3.4" | 111 | version = "3.4" |
| 115 | description = "Internationalized Domain Names in Applications (IDNA)" | 112 | description = "Internationalized Domain Names in Applications (IDNA)" |
| 116 | category = "main" | ||
| 117 | optional = false | 113 | optional = false |
| 118 | python-versions = ">=3.5" | 114 | python-versions = ">=3.5" |
| 119 | files = [ | 115 | files = [ |
| @@ -122,10 +118,25 @@ files = [ | |||
| 122 | ] | 118 | ] |
| 123 | 119 | ||
| 124 | [[package]] | 120 | [[package]] |
| 121 | name = "jsonpickle" | ||
| 122 | version = "3.0.2" | ||
| 123 | description = "Python library for serializing any arbitrary object graph into JSON" | ||
| 124 | optional = false | ||
| 125 | python-versions = ">=3.7" | ||
| 126 | files = [ | ||
| 127 | {file = "jsonpickle-3.0.2-py3-none-any.whl", hash = "sha256:4a8442d97ca3f77978afa58068768dba7bff2dbabe79a9647bc3cdafd4ef019f"}, | ||
| 128 | {file = "jsonpickle-3.0.2.tar.gz", hash = "sha256:e37abba4bfb3ca4a4647d28bb9f4706436f7b46c8a8333b4a718abafa8e46b37"}, | ||
| 129 | ] | ||
| 130 | |||
| 131 | [package.extras] | ||
| 132 | docs = ["jaraco.packaging (>=3.2)", "rst.linker (>=1.9)", "sphinx"] | ||
| 133 | testing = ["ecdsa", "feedparser", "gmpy2", "numpy", "pandas", "pymongo", "pytest (>=3.5,!=3.7.3)", "pytest-black-multipy", "pytest-checkdocs (>=1.2.3)", "pytest-cov", "pytest-flake8 (>=1.1.1)", "scikit-learn", "sqlalchemy"] | ||
| 134 | testing-libs = ["simplejson", "ujson"] | ||
| 135 | |||
| 136 | [[package]] | ||
| 125 | name = "python-dateutil" | 137 | name = "python-dateutil" |
| 126 | version = "2.8.2" | 138 | version = "2.8.2" |
| 127 | description = "Extensions to the standard Python datetime module" | 139 | description = "Extensions to the standard Python datetime module" |
| 128 | category = "main" | ||
| 129 | optional = false | 140 | optional = false |
| 130 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" | 141 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" |
| 131 | files = [ | 142 | files = [ |
| @@ -140,7 +151,6 @@ six = ">=1.5" | |||
| 140 | name = "pyxdg" | 151 | name = "pyxdg" |
| 141 | version = "0.28" | 152 | version = "0.28" |
| 142 | description = "PyXDG contains implementations of freedesktop.org standards in python." | 153 | description = "PyXDG contains implementations of freedesktop.org standards in python." |
| 143 | category = "main" | ||
| 144 | optional = false | 154 | optional = false |
| 145 | python-versions = "*" | 155 | python-versions = "*" |
| 146 | files = [ | 156 | files = [ |
| @@ -152,7 +162,6 @@ files = [ | |||
| 152 | name = "requests" | 162 | name = "requests" |
| 153 | version = "2.28.2" | 163 | version = "2.28.2" |
| 154 | description = "Python HTTP for Humans." | 164 | description = "Python HTTP for Humans." |
| 155 | category = "main" | ||
| 156 | optional = false | 165 | optional = false |
| 157 | python-versions = ">=3.7, <4" | 166 | python-versions = ">=3.7, <4" |
| 158 | files = [ | 167 | files = [ |
| @@ -174,7 +183,6 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] | |||
| 174 | name = "six" | 183 | name = "six" |
| 175 | version = "1.16.0" | 184 | version = "1.16.0" |
| 176 | description = "Python 2 and 3 compatibility utilities" | 185 | description = "Python 2 and 3 compatibility utilities" |
| 177 | category = "main" | ||
| 178 | optional = false | 186 | optional = false |
| 179 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" | 187 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" |
| 180 | files = [ | 188 | files = [ |
| @@ -186,7 +194,6 @@ files = [ | |||
| 186 | name = "tabulate" | 194 | name = "tabulate" |
| 187 | version = "0.9.0" | 195 | version = "0.9.0" |
| 188 | description = "Pretty-print tabular data" | 196 | description = "Pretty-print tabular data" |
| 189 | category = "main" | ||
| 190 | optional = false | 197 | optional = false |
| 191 | python-versions = ">=3.7" | 198 | python-versions = ">=3.7" |
| 192 | files = [ | 199 | files = [ |
| @@ -201,7 +208,6 @@ widechars = ["wcwidth"] | |||
| 201 | name = "toml" | 208 | name = "toml" |
| 202 | version = "0.10.2" | 209 | version = "0.10.2" |
| 203 | description = "Python Library for Tom's Obvious, Minimal Language" | 210 | description = "Python Library for Tom's Obvious, Minimal Language" |
| 204 | category = "main" | ||
| 205 | optional = false | 211 | optional = false |
| 206 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" | 212 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" |
| 207 | files = [ | 213 | files = [ |
| @@ -213,7 +219,6 @@ files = [ | |||
| 213 | name = "uritools" | 219 | name = "uritools" |
| 214 | version = "4.0.1" | 220 | version = "4.0.1" |
| 215 | description = "URI parsing, classification and composition" | 221 | description = "URI parsing, classification and composition" |
| 216 | category = "main" | ||
| 217 | optional = false | 222 | optional = false |
| 218 | python-versions = "~=3.7" | 223 | python-versions = "~=3.7" |
| 219 | files = [ | 224 | files = [ |
| @@ -225,7 +230,6 @@ files = [ | |||
| 225 | name = "urllib3" | 230 | name = "urllib3" |
| 226 | version = "1.26.15" | 231 | version = "1.26.15" |
| 227 | description = "HTTP library with thread-safe connection pooling, file post, and more." | 232 | description = "HTTP library with thread-safe connection pooling, file post, and more." |
| 228 | category = "main" | ||
| 229 | optional = false | 233 | optional = false |
| 230 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" | 234 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" |
| 231 | files = [ | 235 | files = [ |
| @@ -241,4 +245,4 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] | |||
| 241 | [metadata] | 245 | [metadata] |
| 242 | lock-version = "2.0" | 246 | lock-version = "2.0" |
| 243 | python-versions = "^3.10" | 247 | python-versions = "^3.10" |
| 244 | content-hash = "0d556c1b7f4ca6764a006e10ef9949359911925a9dae09d25a3c3d26d8966790" | 248 | content-hash = "d9137b4f8e37bba934abf732e4a2aeeb9924c4b6576830d8ae08bdb43b4e147f" |
diff --git a/overlays/worktime/pyproject.toml b/overlays/worktime/pyproject.toml index 61257422..08002d4d 100644 --- a/overlays/worktime/pyproject.toml +++ b/overlays/worktime/pyproject.toml | |||
| @@ -13,6 +13,7 @@ requests = "^2.28.2" | |||
| 13 | tabulate = "^0.9.0" | 13 | tabulate = "^0.9.0" |
| 14 | backoff = "^2.2.1" | 14 | backoff = "^2.2.1" |
| 15 | toml = "^0.10.2" | 15 | toml = "^0.10.2" |
| 16 | jsonpickle = "^3.0.2" | ||
| 16 | 17 | ||
| 17 | [tool.poetry.scripts] | 18 | [tool.poetry.scripts] |
| 18 | worktime = "worktime.__main__:main" | 19 | worktime = "worktime.__main__:main" |
diff --git a/overlays/worktime/worktime/__main__.py b/overlays/worktime/worktime/__main__.py index 84c8a8e2..0264b8a8 100755 --- a/overlays/worktime/worktime/__main__.py +++ b/overlays/worktime/worktime/__main__.py | |||
| @@ -25,7 +25,7 @@ from sys import stderr | |||
| 25 | 25 | ||
| 26 | from tabulate import tabulate | 26 | from tabulate import tabulate |
| 27 | 27 | ||
| 28 | from itertools import groupby | 28 | from itertools import groupby, count |
| 29 | from functools import cache, partial | 29 | from functools import cache, partial |
| 30 | 30 | ||
| 31 | import backoff | 31 | import backoff |
| @@ -34,6 +34,11 @@ from pathlib import Path | |||
| 34 | 34 | ||
| 35 | from collections import defaultdict | 35 | from collections import defaultdict |
| 36 | 36 | ||
| 37 | import shelve | ||
| 38 | import jsonpickle | ||
| 39 | from hashlib import blake2s | ||
| 40 | |||
| 41 | shelve_d = shelve.open(str(Path(BaseDirectory.save_cache_path('worktime')) / 'entry_durations')) | ||
| 37 | 42 | ||
| 38 | class TogglAPISection(Enum): | 43 | class TogglAPISection(Enum): |
| 39 | TOGGL = '/api/v8' | 44 | TOGGL = '/api/v8' |
| @@ -90,46 +95,79 @@ class TogglAPI(object): | |||
| 90 | 95 | ||
| 91 | return response | 96 | return response |
| 92 | 97 | ||
| 98 | def entry_durations(self, start_date, *, end_date, rounding=False, client_ids): | ||
| 99 | if client_ids is not None and not client_ids: | ||
| 100 | return | ||
| 101 | |||
| 102 | step = timedelta(days = 120) | ||
| 103 | for req_start in (start_date + step * i for i in count()): | ||
| 104 | req_start = min(req_start, end_date) | ||
| 105 | req_end = min(req_start + step, end_date) | ||
| 106 | if req_end <= req_start: | ||
| 107 | break | ||
| 108 | # if end_date > req_start + step: | ||
| 109 | # req_end = datetime.combine((req_start + step).astimezone(timezone.utc).date(), time(tzinfo=timezone.utc)) | ||
| 110 | # elif req_start > start_date: | ||
| 111 | # req_start = datetime.combine(req_start.astimezone(timezone.utc).date(), time(tzinfo=timezone.utc)) + timedelta(days = 1) | ||
| 112 | |||
| 113 | cache_key = None | ||
| 114 | if req_end + timedelta(days=60) < datetime.now().astimezone(timezone.utc): | ||
| 115 | cache_key = blake2s(jsonpickle.encode({ | ||
| 116 | 'start': req_start, | ||
| 117 | 'end': req_end, | ||
| 118 | 'rounding': rounding, | ||
| 119 | 'clients': client_ids, | ||
| 120 | 'workspace': self._workspace_id, | ||
| 121 | 'workspace_clients': self._client_ids | ||
| 122 | }).encode('utf-8'), key = self._api_token.encode('utf-8')).hexdigest() | ||
| 123 | if cache_key in shelve_d: | ||
| 124 | yield from shelve_d[cache_key] | ||
| 125 | continue | ||
| 126 | |||
| 127 | entries = list() | ||
| 128 | params = { 'since': (req_start - timedelta(days=1)).astimezone(timezone.utc).isoformat(), | ||
| 129 | 'until': (req_end + timedelta(days=1)).astimezone(timezone.utc).isoformat(), | ||
| 130 | 'rounding': rounding, | ||
| 131 | 'billable': 'yes' | ||
| 132 | } | ||
| 133 | if client_ids is not None: | ||
| 134 | params |= { 'client_ids': ','.join(map(str, client_ids)) } | ||
| 135 | for page in count(start = 1): | ||
| 136 | url = self._make_url(api = TogglAPISection.REPORTS, section = ['details'], params = params | { 'page': page }) | ||
| 137 | r = self._query(url = url, method='GET') | ||
| 138 | if not r or not r.json(): | ||
| 139 | raise TogglAPIError(r) | ||
| 140 | report = r.json() | ||
| 141 | for entry in report['data']: | ||
| 142 | start = isoparse(entry['start']) | ||
| 143 | end = isoparse(entry['end']) | ||
| 144 | |||
| 145 | if start > req_end or end < req_start: | ||
| 146 | continue | ||
| 147 | |||
| 148 | x = min(end, req_end) - max(start, req_start) | ||
| 149 | if cache_key: | ||
| 150 | entries.append(x) | ||
| 151 | yield x | ||
| 152 | if not report['data']: | ||
| 153 | break | ||
| 154 | |||
| 155 | if cache_key: | ||
| 156 | shelve_d[cache_key] = entries | ||
| 157 | # res = timedelta(milliseconds=report['total_billable']) if report['total_billable'] else timedelta(milliseconds=0) | ||
| 158 | # return res | ||
| 159 | |||
| 93 | def get_billable_hours(self, start_date, end_date=datetime.now(timezone.utc), rounding=False): | 160 | def get_billable_hours(self, start_date, end_date=datetime.now(timezone.utc), rounding=False): |
| 94 | billable_acc = timedelta(milliseconds = 0) | 161 | billable_acc = timedelta(milliseconds = 0) |
| 95 | step = timedelta(days = 365) | 162 | if 0 in self._client_ids: |
| 96 | 163 | url = self._make_url(api = TogglAPISection.TOGGL, section = ['workspaces', self._workspace_id, 'clients']) | |
| 97 | for req_start in [start_date + x * step for x in range(0, ceil((end_date - start_date) / step))]: | 164 | r = self._query(url = url, method = 'GET') |
| 98 | req_end = end_date | 165 | if not r or not r.json(): |
| 99 | if end_date > req_start + step: | 166 | raise TogglAPIError(r) |
| 100 | req_end = datetime.combine((req_start + step).astimezone(timezone.utc).date(), time(tzinfo=timezone.utc)) | 167 | |
| 101 | elif req_start > start_date: | 168 | billable_acc += sum(self.entry_durations(start_date, end_date=end_date, rounding=rounding, client_ids=None), start=timedelta(milliseconds=0)) - sum(self.entry_durations(start_date, end_date=end_date, rounding=rounding, client_ids=frozenset(map(lambda c: c['id'], r.json()))), start=timedelta(milliseconds=0)) |
| 102 | req_start = datetime.combine(req_start.astimezone(timezone.utc).date(), time(tzinfo=timezone.utc)) + timedelta(days = 1) | 169 | |
| 103 | 170 | billable_acc += sum(self.entry_durations(start_date, end_date=end_date, rounding=rounding, client_ids=frozenset(*(self._client_ids - {0}))), start=timedelta(milliseconds=0)) | |
| 104 | def get_report(client_ids = self._client_ids): | ||
| 105 | nonlocal req_start, req_end, rounding, self | ||
| 106 | |||
| 107 | if client_ids is not None and not client_ids: | ||
| 108 | return timedelta(milliseconds = 0) | ||
| 109 | |||
| 110 | params = { 'since': req_start.astimezone(timezone.utc).isoformat(), | ||
| 111 | 'until': req_end.astimezone(timezone.utc).isoformat(), | ||
| 112 | 'rounding': rounding, | ||
| 113 | 'billable': 'yes' | ||
| 114 | } | ||
| 115 | if client_ids is not None: | ||
| 116 | params |= { 'client_ids': ','.join(map(str, client_ids)) } | ||
| 117 | url = self._make_url(api = TogglAPISection.REPORTS, section = ['summary'], params = params) | ||
| 118 | r = self._query(url = url, method='GET') | ||
| 119 | if not r or not r.json(): | ||
| 120 | raise TogglAPIError(r) | ||
| 121 | res = timedelta(milliseconds=r.json()['total_billable']) if r.json()['total_billable'] else timedelta(milliseconds=0) | ||
| 122 | return res | ||
| 123 | |||
| 124 | if 0 in self._client_ids: | ||
| 125 | url = self._make_url(api = TogglAPISection.TOGGL, section = ['workspaces', self._workspace_id, 'clients']) | ||
| 126 | r = self._query(url = url, method = 'GET') | ||
| 127 | if not r or not r.json(): | ||
| 128 | raise TogglAPIError(r) | ||
| 129 | |||
| 130 | billable_acc += get_report(None) - get_report(set(map(lambda c: c['id'], r.json()))) | ||
| 131 | |||
| 132 | billable_acc += get_report(self._client_ids - {0}) | ||
| 133 | 171 | ||
| 134 | return billable_acc | 172 | return billable_acc |
| 135 | 173 | ||
| @@ -512,6 +550,7 @@ def time_worked(now, **args): | |||
| 512 | then = Worktime(**dict(args, now = then)) | 550 | then = Worktime(**dict(args, now = then)) |
| 513 | now = Worktime(**dict(args, now = now)) | 551 | now = Worktime(**dict(args, now = now)) |
| 514 | 552 | ||
| 553 | print(now.time_worked) | ||
| 515 | worked = now.time_worked - then.time_worked | 554 | worked = now.time_worked - then.time_worked |
| 516 | 555 | ||
| 517 | if args['do_round']: | 556 | if args['do_round']: |
| @@ -730,10 +769,14 @@ def classification(classification_name, table, table_format, **args): | |||
| 730 | )) | 769 | )) |
| 731 | 770 | ||
| 732 | def main(): | 771 | def main(): |
| 772 | def isotime(s): | ||
| 773 | return datetime.fromisoformat(s).replace(tzinfo=tzlocal()) | ||
| 774 | |||
| 733 | config = Worktime.config() | 775 | config = Worktime.config() |
| 734 | 776 | ||
| 735 | parser = argparse.ArgumentParser(prog = "worktime", description = 'Track worktime using toggl API') | 777 | parser = argparse.ArgumentParser(prog = "worktime", description = 'Track worktime using toggl API') |
| 736 | 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())) | 778 | parser.add_argument('--time', dest = 'now', metavar = 'TIME', type = isotime, help = 'Time to calculate status for (default: current time)', default = datetime.now(tzlocal())) |
| 779 | parser.add_argument('--start', dest = 'start_datetime', metavar = 'TIME', type = isotime, help = 'Time to calculate status from (default: None)', default = None) | ||
| 737 | parser.add_argument('--no-running', dest = 'include_running', action = 'store_false') | 780 | parser.add_argument('--no-running', dest = 'include_running', action = 'store_false') |
| 738 | parser.add_argument('--no-force-day-to-work', dest = 'force_day_to_work', action = 'store_false') | 781 | parser.add_argument('--no-force-day-to-work', dest = 'force_day_to_work', action = 'store_false') |
| 739 | subparsers = parser.add_subparsers(help = 'Subcommands') | 782 | subparsers = parser.add_subparsers(help = 'Subcommands') |
