diff options
author | Gregor Kleen <gkleen@yggdrasil.li> | 2023-10-05 21:47:33 +0200 |
---|---|---|
committer | Gregor Kleen <gkleen@yggdrasil.li> | 2023-10-05 21:47:33 +0200 |
commit | 01a28fd186c9f03aeea1e21a778d1c1a3e07f3f6 (patch) | |
tree | fff4ba60b14c867c282990eb839c391f20f810b3 | |
parent | aa02038dfa3005a910e8c1b0885843786c8aa58c (diff) | |
download | nixos-01a28fd186c9f03aeea1e21a778d1c1a3e07f3f6.tar nixos-01a28fd186c9f03aeea1e21a778d1c1a3e07f3f6.tar.gz nixos-01a28fd186c9f03aeea1e21a778d1c1a3e07f3f6.tar.bz2 nixos-01a28fd186c9f03aeea1e21a778d1c1a3e07f3f6.tar.xz nixos-01a28fd186c9f03aeea1e21a778d1c1a3e07f3f6.zip |
...
-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') |