timedctl 5.9.2__tar.gz → 5.10.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {timedctl-5.9.2 → timedctl-5.10.2}/PKG-INFO +9 -4
- {timedctl-5.9.2 → timedctl-5.10.2}/README.md +1 -2
- timedctl-5.10.2/pyproject.toml +116 -0
- {timedctl-5.9.2 → timedctl-5.10.2}/timedctl/helpers.py +1 -0
- {timedctl-5.9.2 → timedctl-5.10.2}/timedctl/timedctl.py +158 -24
- timedctl-5.9.2/pyproject.toml +0 -53
- {timedctl-5.9.2 → timedctl-5.10.2}/LICENSE +0 -0
- {timedctl-5.9.2 → timedctl-5.10.2}/timedctl/__init__.py +0 -0
- {timedctl-5.9.2 → timedctl-5.10.2}/timedctl/cli.py +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: timedctl
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.10.2
|
|
4
4
|
Summary: CLI for timed
|
|
5
5
|
License: AGPL-3.0-only
|
|
6
|
+
License-File: LICENSE
|
|
6
7
|
Author: Arthur Deierlein
|
|
7
8
|
Author-email: arthur.deierlein@adfinis.com
|
|
8
9
|
Requires-Python: >=3.11,<4.0
|
|
@@ -10,10 +11,15 @@ Classifier: License :: OSI Approved :: GNU Affero General Public License v3
|
|
|
10
11
|
Classifier: Programming Language :: Python :: 3
|
|
11
12
|
Classifier: Programming Language :: Python :: 3.11
|
|
12
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
16
|
Requires-Dist: click (>=8.1.3,<9.0.0)
|
|
14
17
|
Requires-Dist: click-aliases (>=1.0.1,<2.0.0)
|
|
18
|
+
Requires-Dist: keyring (>=24.1,<26.0)
|
|
15
19
|
Requires-Dist: libtimed (>=0.6.4,<0.7.0)
|
|
16
20
|
Requires-Dist: pyfzf (>=0.3.1,<0.4.0)
|
|
21
|
+
Requires-Dist: pyjwt (>=2.8.0,<3.0.0)
|
|
22
|
+
Requires-Dist: requests (>=2.31.0,<3.0.0)
|
|
17
23
|
Requires-Dist: rich (>=13.4.2,<14.0.0)
|
|
18
24
|
Requires-Dist: tomlkit (>=0.11.8,<0.13.0)
|
|
19
25
|
Description-Content-Type: text/markdown
|
|
@@ -94,8 +100,7 @@ If this isn't the case you can see the default config options below:
|
|
|
94
100
|
```toml
|
|
95
101
|
username = "<your username>"
|
|
96
102
|
timed_url = "<timed url>"
|
|
97
|
-
|
|
98
|
-
sso_realm = "<sso realm>"
|
|
103
|
+
sso_discovery_url = "<sso url>"
|
|
99
104
|
sso_client_id = "<client id>"
|
|
100
105
|
```
|
|
101
106
|
|
|
@@ -74,8 +74,7 @@ If this isn't the case you can see the default config options below:
|
|
|
74
74
|
```toml
|
|
75
75
|
username = "<your username>"
|
|
76
76
|
timed_url = "<timed url>"
|
|
77
|
-
|
|
78
|
-
sso_realm = "<sso realm>"
|
|
77
|
+
sso_discovery_url = "<sso url>"
|
|
79
78
|
sso_client_id = "<client id>"
|
|
80
79
|
```
|
|
81
80
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "timedctl"
|
|
3
|
+
version = "5.10.2"
|
|
4
|
+
description = "CLI for timed"
|
|
5
|
+
authors = [
|
|
6
|
+
"Arthur Deierlein <arthur.deierlein@adfinis.com>",
|
|
7
|
+
"Gian Klug <gian.klug@adfinis.com>",
|
|
8
|
+
]
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "AGPL-3.0-only"
|
|
11
|
+
|
|
12
|
+
[tool.poetry.dependencies]
|
|
13
|
+
python = "^3.11"
|
|
14
|
+
click = "^8.1.3"
|
|
15
|
+
pyfzf = "^0.3.1"
|
|
16
|
+
rich = "^13.4.2"
|
|
17
|
+
tomlkit = ">=0.11.8,<0.13.0"
|
|
18
|
+
click-aliases = "^1.0.1"
|
|
19
|
+
libtimed = "^0.6.4"
|
|
20
|
+
keyring = ">=24.1,<26.0"
|
|
21
|
+
requests = "^2.31.0"
|
|
22
|
+
pyjwt = "^2.8.0"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
[tool.poetry.group.dev.dependencies]
|
|
26
|
+
pytest = ">=7.3.2,<9.0.0"
|
|
27
|
+
isort = "^5.12.0"
|
|
28
|
+
ruff = "v0.3.4"
|
|
29
|
+
pytest-cov = ">=4.1,<6.0"
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["poetry-core"]
|
|
33
|
+
build-backend = "poetry.core.masonry.api"
|
|
34
|
+
|
|
35
|
+
[tool.poetry.scripts]
|
|
36
|
+
timedctl = "timedctl.cli:timedctl"
|
|
37
|
+
|
|
38
|
+
[tool.semantic_release]
|
|
39
|
+
version_toml = ["pyproject.toml:tool.poetry.version"]
|
|
40
|
+
major_on_zero = false
|
|
41
|
+
branch = "main"
|
|
42
|
+
build_command = "pip install poetry && poetry build"
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
line-length = 88
|
|
46
|
+
|
|
47
|
+
[tool.ruff.format]
|
|
48
|
+
quote-style = "double"
|
|
49
|
+
indent-style = "space"
|
|
50
|
+
docstring-code-format = true
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint]
|
|
53
|
+
# TODO: add "ANN" again in the future
|
|
54
|
+
select = [
|
|
55
|
+
"E",
|
|
56
|
+
"F",
|
|
57
|
+
"C4",
|
|
58
|
+
"PL",
|
|
59
|
+
"C90",
|
|
60
|
+
"I",
|
|
61
|
+
"N",
|
|
62
|
+
"UP",
|
|
63
|
+
"B",
|
|
64
|
+
"S",
|
|
65
|
+
"A",
|
|
66
|
+
"COM",
|
|
67
|
+
"PT",
|
|
68
|
+
"Q",
|
|
69
|
+
"T20",
|
|
70
|
+
"SLF",
|
|
71
|
+
"TD",
|
|
72
|
+
"FIX",
|
|
73
|
+
"PIE",
|
|
74
|
+
]
|
|
75
|
+
fixable = ["ALL"]
|
|
76
|
+
ignore = [
|
|
77
|
+
# make docstrings optional
|
|
78
|
+
"D100",
|
|
79
|
+
"D101",
|
|
80
|
+
"D102",
|
|
81
|
+
"D103",
|
|
82
|
+
"D104",
|
|
83
|
+
"D105",
|
|
84
|
+
"D106",
|
|
85
|
+
"D107",
|
|
86
|
+
"D211",
|
|
87
|
+
"D213",
|
|
88
|
+
"RUF012",
|
|
89
|
+
# line length is handled by formatter
|
|
90
|
+
"E501",
|
|
91
|
+
# disable this for now
|
|
92
|
+
"SLF001",
|
|
93
|
+
# https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
|
|
94
|
+
"W191",
|
|
95
|
+
"E111",
|
|
96
|
+
"E114",
|
|
97
|
+
"E117",
|
|
98
|
+
"D206",
|
|
99
|
+
"D300",
|
|
100
|
+
"Q000",
|
|
101
|
+
"Q001",
|
|
102
|
+
"Q002",
|
|
103
|
+
"Q003",
|
|
104
|
+
"COM812",
|
|
105
|
+
"COM819",
|
|
106
|
+
"ISC001",
|
|
107
|
+
"ISC002",
|
|
108
|
+
# this conflicts for some reason
|
|
109
|
+
"D203"
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
[tool.ruff.lint.pylint]
|
|
113
|
+
max-args = 7
|
|
114
|
+
|
|
115
|
+
[tool.ruff.lint.per-file-ignores]
|
|
116
|
+
"tests/*" = ["S101"]
|
|
@@ -3,15 +3,18 @@
|
|
|
3
3
|
|
|
4
4
|
import os
|
|
5
5
|
import re
|
|
6
|
+
import time
|
|
7
|
+
import webbrowser
|
|
6
8
|
from datetime import datetime, timedelta
|
|
7
9
|
|
|
8
10
|
import click
|
|
11
|
+
import jwt
|
|
12
|
+
import keyring
|
|
9
13
|
import pyfzf
|
|
10
14
|
import requests
|
|
11
15
|
import tomllib
|
|
12
16
|
from libtimed import TimedAPIClient
|
|
13
|
-
from
|
|
14
|
-
from rich import print
|
|
17
|
+
from rich import print as rprint
|
|
15
18
|
from rich.table import Table
|
|
16
19
|
from tomlkit import dump
|
|
17
20
|
|
|
@@ -23,6 +26,8 @@ from timedctl.helpers import (
|
|
|
23
26
|
time_picker,
|
|
24
27
|
)
|
|
25
28
|
|
|
29
|
+
TIMEOUT = 30
|
|
30
|
+
|
|
26
31
|
|
|
27
32
|
class Timedctl:
|
|
28
33
|
def __init__(self):
|
|
@@ -33,8 +38,7 @@ class Timedctl:
|
|
|
33
38
|
cfg = {
|
|
34
39
|
"username": "test",
|
|
35
40
|
"timed_url": "https://timed.example.com",
|
|
36
|
-
"
|
|
37
|
-
"sso_realm": "example",
|
|
41
|
+
"sso_discovery_url": "https://sso.example.com/realms/example",
|
|
38
42
|
"sso_client_id": "timedctl",
|
|
39
43
|
}
|
|
40
44
|
|
|
@@ -56,13 +60,29 @@ class Timedctl:
|
|
|
56
60
|
if not os.path.isfile(config_file):
|
|
57
61
|
os.makedirs(config_dir, exist_ok=True)
|
|
58
62
|
click.echo("No config file found. Please enter the following infos.")
|
|
59
|
-
for key in cfg:
|
|
60
|
-
cfg[key] = input(f"{key} ({
|
|
63
|
+
for key, value in cfg.items():
|
|
64
|
+
cfg[key] = input(f"{key} ({value}): ")
|
|
61
65
|
with open(config_file, "w", encoding="utf-8") as file:
|
|
62
66
|
dump(cfg, file)
|
|
63
67
|
else:
|
|
64
68
|
with open(config_file, "rb") as file:
|
|
65
69
|
user_config = tomllib.load(file)
|
|
70
|
+
|
|
71
|
+
# Migration from sso_url & sso_realm to sso_discovery_url
|
|
72
|
+
if (
|
|
73
|
+
"sso_discovery_url" not in user_config
|
|
74
|
+
and "sso_url" in user_config
|
|
75
|
+
and "sso_realm" in user_config
|
|
76
|
+
):
|
|
77
|
+
user_config["sso_discovery_url"] = (
|
|
78
|
+
user_config["sso_url"] + "/realms/" + user_config["sso_realm"]
|
|
79
|
+
)
|
|
80
|
+
del user_config["sso_url"]
|
|
81
|
+
del user_config["sso_realm"]
|
|
82
|
+
|
|
83
|
+
with open(config_file, "w", encoding="utf-8") as file:
|
|
84
|
+
dump(user_config, file)
|
|
85
|
+
|
|
66
86
|
for key in user_config:
|
|
67
87
|
cfg[key] = user_config[key]
|
|
68
88
|
self.config = cfg
|
|
@@ -72,24 +92,138 @@ class Timedctl:
|
|
|
72
92
|
# initialize libtimed
|
|
73
93
|
url = self.config.get("timed_url")
|
|
74
94
|
api_namespace = "api/v1"
|
|
95
|
+
client_id = self.config["sso_client_id"]
|
|
96
|
+
|
|
97
|
+
access_token = keyring.get_password(
|
|
98
|
+
"system", "timedctl_token_" + client_id + "_access"
|
|
99
|
+
)
|
|
100
|
+
decode_options = {"verify_signature": False, "verify_exp": "verify_signature"}
|
|
101
|
+
try:
|
|
102
|
+
# Check if access_token is valid
|
|
103
|
+
jwt.decode(access_token, leeway=-30, options=decode_options)
|
|
104
|
+
|
|
105
|
+
except (jwt.exceptions.ExpiredSignatureError, jwt.exceptions.DecodeError):
|
|
106
|
+
# Access token expired or missing
|
|
107
|
+
refresh_token = keyring.get_password(
|
|
108
|
+
"system", "timedctl_token_" + client_id + "_refresh"
|
|
109
|
+
)
|
|
110
|
+
try:
|
|
111
|
+
jwt.decode(refresh_token, leeway=-10, options=decode_options)
|
|
112
|
+
access_token = self.refresh_token(refresh_token, no_renew_token)
|
|
113
|
+
|
|
114
|
+
except (jwt.exceptions.ExpiredSignatureError, jwt.exceptions.DecodeError):
|
|
115
|
+
# Refresh token expired or missing
|
|
116
|
+
if no_renew_token:
|
|
117
|
+
error_handler("ERR_TOKEN_MISSING_OR_EXPIRED")
|
|
118
|
+
|
|
119
|
+
access_token = self.login()
|
|
75
120
|
|
|
76
|
-
|
|
121
|
+
self.timed = TimedAPIClient(access_token, url, api_namespace)
|
|
122
|
+
|
|
123
|
+
def get_openid_configuration(self):
|
|
124
|
+
"""Return the OpenID configuration."""
|
|
125
|
+
sso_discovery_url = self.config.get("sso_discovery_url")
|
|
126
|
+
|
|
127
|
+
# Retrieve OpenID configuration
|
|
128
|
+
openid_configuration = requests.get(
|
|
129
|
+
f"{sso_discovery_url}/.well-known/openid-configuration", timeout=TIMEOUT
|
|
130
|
+
).json()
|
|
131
|
+
if "error" in openid_configuration:
|
|
132
|
+
error_handler("ERR_COULD_NOT_GET_OPENID_CONFIGURATION")
|
|
133
|
+
|
|
134
|
+
return openid_configuration
|
|
135
|
+
|
|
136
|
+
def login(self):
|
|
137
|
+
"""Authenticates using device code."""
|
|
77
138
|
client_id = self.config.get("sso_client_id")
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
139
|
+
openid_configuration = self.get_openid_configuration()
|
|
140
|
+
|
|
141
|
+
if (
|
|
142
|
+
"urn:ietf:params:oauth:grant-type:device_code"
|
|
143
|
+
not in openid_configuration["grant_types_supported"]
|
|
144
|
+
):
|
|
145
|
+
error_handler("ERR_SSO_DOES_NOT_SUPPORT_DEVICE_CODE")
|
|
146
|
+
|
|
147
|
+
device_code_payload = {"client_id": client_id, "scope": "openid"}
|
|
148
|
+
device_code_response = requests.post(
|
|
149
|
+
openid_configuration["device_authorization_endpoint"],
|
|
150
|
+
data=device_code_payload,
|
|
151
|
+
timeout=TIMEOUT,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if device_code_response.status_code != requests.codes.ok:
|
|
155
|
+
error_handler("ERR_GENERATING_DEVICE_CODE")
|
|
156
|
+
|
|
157
|
+
device_code_data = device_code_response.json()
|
|
158
|
+
rprint(
|
|
159
|
+
"A web browser was opened at {}. Please continue the login in the web browser.".format(
|
|
160
|
+
device_code_data["verification_uri_complete"]
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
webbrowser.open_new(device_code_data["verification_uri_complete"])
|
|
164
|
+
|
|
165
|
+
token_payload = {
|
|
166
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
167
|
+
"device_code": device_code_data["device_code"],
|
|
168
|
+
"client_id": client_id,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
while True:
|
|
172
|
+
token_response = requests.post(
|
|
173
|
+
openid_configuration["token_endpoint"],
|
|
174
|
+
data=token_payload,
|
|
175
|
+
timeout=TIMEOUT,
|
|
176
|
+
)
|
|
177
|
+
token_data = token_response.json()
|
|
178
|
+
|
|
179
|
+
if token_response.status_code == requests.codes.ok:
|
|
180
|
+
keyring.set_password(
|
|
181
|
+
"system",
|
|
182
|
+
"timedctl_token_" + client_id + "_access",
|
|
183
|
+
token_data["access_token"],
|
|
184
|
+
)
|
|
185
|
+
keyring.set_password(
|
|
186
|
+
"system",
|
|
187
|
+
"timedctl_token_" + client_id + "_refresh",
|
|
188
|
+
token_data["refresh_token"],
|
|
189
|
+
)
|
|
190
|
+
return token_data["access_token"]
|
|
191
|
+
elif token_data["error"] not in ("authorization_pending", "slow_down"):
|
|
192
|
+
rprint(token_data["error_description"])
|
|
193
|
+
error_handler("ERR_AUTHORIZATION_FAILED")
|
|
194
|
+
else:
|
|
195
|
+
time.sleep(device_code_data["interval"])
|
|
196
|
+
|
|
197
|
+
def refresh_token(self, token, no_renew_token=False):
|
|
198
|
+
"""Refresh token."""
|
|
199
|
+
client_id = self.config.get("sso_client_id")
|
|
200
|
+
openid_configuration = self.get_openid_configuration()
|
|
201
|
+
|
|
202
|
+
if "refresh_token" not in openid_configuration["grant_types_supported"]:
|
|
203
|
+
error_handler("ERR_SSO_DOES_NOT_SUPPORT_REFRESH")
|
|
204
|
+
|
|
205
|
+
token_payload = {
|
|
206
|
+
"grant_type": "refresh_token",
|
|
207
|
+
"refresh_token": token,
|
|
208
|
+
"client_id": client_id,
|
|
209
|
+
}
|
|
210
|
+
token_response = requests.post(
|
|
211
|
+
openid_configuration["token_endpoint"], data=token_payload, timeout=TIMEOUT
|
|
212
|
+
)
|
|
213
|
+
token_data = token_response.json()
|
|
214
|
+
|
|
215
|
+
if token_response.status_code != requests.codes.ok:
|
|
216
|
+
if no_renew_token:
|
|
217
|
+
error_handler("ERR_REFRESHING_TOKEN")
|
|
218
|
+
|
|
219
|
+
return self.login()
|
|
220
|
+
|
|
221
|
+
keyring.set_password(
|
|
222
|
+
"system",
|
|
223
|
+
"timedctl_token_" + client_id + "_access",
|
|
224
|
+
token_data["access_token"],
|
|
225
|
+
)
|
|
226
|
+
return token_data["access_token"]
|
|
93
227
|
|
|
94
228
|
def force_renew(self):
|
|
95
229
|
"""Force a token renewal."""
|
|
@@ -384,7 +518,7 @@ class Timedctl:
|
|
|
384
518
|
|
|
385
519
|
table.add_row(customer, project, task, comment, str(duration))
|
|
386
520
|
table.add_row("", "", "", "", str(total))
|
|
387
|
-
|
|
521
|
+
rprint(table)
|
|
388
522
|
|
|
389
523
|
def get_activities(self, date):
|
|
390
524
|
"""Get activities."""
|
|
@@ -428,7 +562,7 @@ class Timedctl:
|
|
|
428
562
|
|
|
429
563
|
table.add_row(activity_fmt, comment, from_time_fmt, to_time_fmt)
|
|
430
564
|
|
|
431
|
-
|
|
565
|
+
rprint(table)
|
|
432
566
|
msg(f"Total: {total_time}")
|
|
433
567
|
|
|
434
568
|
def delete_report(self, date):
|
timedctl-5.9.2/pyproject.toml
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
[tool.poetry]
|
|
2
|
-
name = "timedctl"
|
|
3
|
-
version = "5.9.2"
|
|
4
|
-
description = "CLI for timed"
|
|
5
|
-
authors = ["Arthur Deierlein <arthur.deierlein@adfinis.com>", "Gian Klug <gian.klug@adfinis.com>"]
|
|
6
|
-
readme = "README.md"
|
|
7
|
-
license = "AGPL-3.0-only"
|
|
8
|
-
|
|
9
|
-
[tool.poetry.dependencies]
|
|
10
|
-
python = "^3.11"
|
|
11
|
-
click = "^8.1.3"
|
|
12
|
-
pyfzf = "^0.3.1"
|
|
13
|
-
rich = "^13.4.2"
|
|
14
|
-
tomlkit = ">=0.11.8,<0.13.0"
|
|
15
|
-
click-aliases = "^1.0.1"
|
|
16
|
-
libtimed = "^0.6.4"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
[tool.poetry.group.dev.dependencies]
|
|
20
|
-
black = "^23.3.0"
|
|
21
|
-
pytest = "^7.3.2"
|
|
22
|
-
isort = "^5.12.0"
|
|
23
|
-
flake8 = "^6.0.0"
|
|
24
|
-
ruff = "v0.1.5"
|
|
25
|
-
pytest-cov = "^4.1.0"
|
|
26
|
-
|
|
27
|
-
[build-system]
|
|
28
|
-
requires = ["poetry-core"]
|
|
29
|
-
build-backend = "poetry.core.masonry.api"
|
|
30
|
-
|
|
31
|
-
[tool.poetry.scripts]
|
|
32
|
-
timedctl = "timedctl.cli:timedctl"
|
|
33
|
-
|
|
34
|
-
[tool.semantic_release]
|
|
35
|
-
version_toml = [
|
|
36
|
-
"pyproject.toml:tool.poetry.version"
|
|
37
|
-
]
|
|
38
|
-
major_on_zero = false
|
|
39
|
-
branch = "main"
|
|
40
|
-
build_command = "pip install poetry && poetry build"
|
|
41
|
-
|
|
42
|
-
[tool.ruff]
|
|
43
|
-
# TODO: add "ANN" again in the future
|
|
44
|
-
select = ["E", "F", "C4", "PL", "C90", "I", "N", "UP", "B", "S", "A", "COM", "PT", "Q", "T20", "SLF", "TD", "FIX", "PIE"]
|
|
45
|
-
# same as black
|
|
46
|
-
line-length = 88
|
|
47
|
-
output-format = "github"
|
|
48
|
-
fixable = ["ALL"]
|
|
49
|
-
# ignore these for now
|
|
50
|
-
ignore = ["PLR0913"]
|
|
51
|
-
|
|
52
|
-
[tool.ruff.per-file-ignores]
|
|
53
|
-
"tests/*" = ["S101"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|