summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--overlays/worktime/poetry.lock32
-rw-r--r--overlays/worktime/pyproject.toml1
-rwxr-xr-xoverlays/worktime/worktime/__main__.py123
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]]
4name = "backoff" 4name = "backoff"
5version = "2.2.1" 5version = "2.2.1"
6description = "Function decoration for backoff and retry" 6description = "Function decoration for backoff and retry"
7category = "main"
8optional = false 7optional = false
9python-versions = ">=3.7,<4.0" 8python-versions = ">=3.7,<4.0"
10files = [ 9files = [
@@ -16,7 +15,6 @@ files = [
16name = "certifi" 15name = "certifi"
17version = "2022.12.7" 16version = "2022.12.7"
18description = "Python package for providing Mozilla's CA Bundle." 17description = "Python package for providing Mozilla's CA Bundle."
19category = "main"
20optional = false 18optional = false
21python-versions = ">=3.6" 19python-versions = ">=3.6"
22files = [ 20files = [
@@ -28,7 +26,6 @@ files = [
28name = "charset-normalizer" 26name = "charset-normalizer"
29version = "3.1.0" 27version = "3.1.0"
30description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 28description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
31category = "main"
32optional = false 29optional = false
33python-versions = ">=3.7.0" 30python-versions = ">=3.7.0"
34files = [ 31files = [
@@ -113,7 +110,6 @@ files = [
113name = "idna" 110name = "idna"
114version = "3.4" 111version = "3.4"
115description = "Internationalized Domain Names in Applications (IDNA)" 112description = "Internationalized Domain Names in Applications (IDNA)"
116category = "main"
117optional = false 113optional = false
118python-versions = ">=3.5" 114python-versions = ">=3.5"
119files = [ 115files = [
@@ -122,10 +118,25 @@ files = [
122] 118]
123 119
124[[package]] 120[[package]]
121name = "jsonpickle"
122version = "3.0.2"
123description = "Python library for serializing any arbitrary object graph into JSON"
124optional = false
125python-versions = ">=3.7"
126files = [
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]
132docs = ["jaraco.packaging (>=3.2)", "rst.linker (>=1.9)", "sphinx"]
133testing = ["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"]
134testing-libs = ["simplejson", "ujson"]
135
136[[package]]
125name = "python-dateutil" 137name = "python-dateutil"
126version = "2.8.2" 138version = "2.8.2"
127description = "Extensions to the standard Python datetime module" 139description = "Extensions to the standard Python datetime module"
128category = "main"
129optional = false 140optional = false
130python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 141python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
131files = [ 142files = [
@@ -140,7 +151,6 @@ six = ">=1.5"
140name = "pyxdg" 151name = "pyxdg"
141version = "0.28" 152version = "0.28"
142description = "PyXDG contains implementations of freedesktop.org standards in python." 153description = "PyXDG contains implementations of freedesktop.org standards in python."
143category = "main"
144optional = false 154optional = false
145python-versions = "*" 155python-versions = "*"
146files = [ 156files = [
@@ -152,7 +162,6 @@ files = [
152name = "requests" 162name = "requests"
153version = "2.28.2" 163version = "2.28.2"
154description = "Python HTTP for Humans." 164description = "Python HTTP for Humans."
155category = "main"
156optional = false 165optional = false
157python-versions = ">=3.7, <4" 166python-versions = ">=3.7, <4"
158files = [ 167files = [
@@ -174,7 +183,6 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
174name = "six" 183name = "six"
175version = "1.16.0" 184version = "1.16.0"
176description = "Python 2 and 3 compatibility utilities" 185description = "Python 2 and 3 compatibility utilities"
177category = "main"
178optional = false 186optional = false
179python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 187python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
180files = [ 188files = [
@@ -186,7 +194,6 @@ files = [
186name = "tabulate" 194name = "tabulate"
187version = "0.9.0" 195version = "0.9.0"
188description = "Pretty-print tabular data" 196description = "Pretty-print tabular data"
189category = "main"
190optional = false 197optional = false
191python-versions = ">=3.7" 198python-versions = ">=3.7"
192files = [ 199files = [
@@ -201,7 +208,6 @@ widechars = ["wcwidth"]
201name = "toml" 208name = "toml"
202version = "0.10.2" 209version = "0.10.2"
203description = "Python Library for Tom's Obvious, Minimal Language" 210description = "Python Library for Tom's Obvious, Minimal Language"
204category = "main"
205optional = false 211optional = false
206python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 212python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
207files = [ 213files = [
@@ -213,7 +219,6 @@ files = [
213name = "uritools" 219name = "uritools"
214version = "4.0.1" 220version = "4.0.1"
215description = "URI parsing, classification and composition" 221description = "URI parsing, classification and composition"
216category = "main"
217optional = false 222optional = false
218python-versions = "~=3.7" 223python-versions = "~=3.7"
219files = [ 224files = [
@@ -225,7 +230,6 @@ files = [
225name = "urllib3" 230name = "urllib3"
226version = "1.26.15" 231version = "1.26.15"
227description = "HTTP library with thread-safe connection pooling, file post, and more." 232description = "HTTP library with thread-safe connection pooling, file post, and more."
228category = "main"
229optional = false 233optional = false
230python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 234python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
231files = [ 235files = [
@@ -241,4 +245,4 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
241[metadata] 245[metadata]
242lock-version = "2.0" 246lock-version = "2.0"
243python-versions = "^3.10" 247python-versions = "^3.10"
244content-hash = "0d556c1b7f4ca6764a006e10ef9949359911925a9dae09d25a3c3d26d8966790" 248content-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"
13tabulate = "^0.9.0" 13tabulate = "^0.9.0"
14backoff = "^2.2.1" 14backoff = "^2.2.1"
15toml = "^0.10.2" 15toml = "^0.10.2"
16jsonpickle = "^3.0.2"
16 17
17[tool.poetry.scripts] 18[tool.poetry.scripts]
18worktime = "worktime.__main__:main" 19worktime = "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
26from tabulate import tabulate 26from tabulate import tabulate
27 27
28from itertools import groupby 28from itertools import groupby, count
29from functools import cache, partial 29from functools import cache, partial
30 30
31import backoff 31import backoff
@@ -34,6 +34,11 @@ from pathlib import Path
34 34
35from collections import defaultdict 35from collections import defaultdict
36 36
37import shelve
38import jsonpickle
39from hashlib import blake2s
40
41shelve_d = shelve.open(str(Path(BaseDirectory.save_cache_path('worktime')) / 'entry_durations'))
37 42
38class TogglAPISection(Enum): 43class 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
732def main(): 771def 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')