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.
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: timedctl
3
- Version: 5.9.2
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
- sso_url = "<sso url>"
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
- sso_url = "<sso url>"
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"]
@@ -1,6 +1,7 @@
1
1
  """
2
2
  API unrelated helper functions.
3
3
  """
4
+
4
5
  import json
5
6
  import re
6
7
  import sys
@@ -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 libtimed.oidc import OIDCClient
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
- "sso_url": "https://sso.example.com",
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} ({cfg[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
- # Auth stuff
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
- sso_url = self.config.get("sso_url")
79
- sso_realm = self.config.get("sso_realm")
80
- auth_path = "timedctl/auth"
81
- self.oidc_client = OIDCClient(client_id, sso_url, sso_realm, auth_path)
82
-
83
- # don't auto-refresh the token if asked
84
- if no_renew_token:
85
- token = self.oidc_client.keyring_get()
86
- if not token:
87
- error_handler("ERR_NO_TOKEN")
88
- if not self.oidc_client.check_expired(token):
89
- error_handler("ERR_TOKEN_EXPIRED")
90
-
91
- token = self.oidc_client.authorize()
92
- self.timed = TimedAPIClient(token, url, api_namespace)
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
- print(table)
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
- print(table)
565
+ rprint(table)
432
566
  msg(f"Total: {total_time}")
433
567
 
434
568
  def delete_report(self, date):
@@ -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