Skip to content

Commit 6927b3a

Browse files
committed
feat: add new features to cli
1 parent ee475c1 commit 6927b3a

1 file changed

Lines changed: 120 additions & 32 deletions

File tree

cli/open-data-capture

Lines changed: 120 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ if sys.version_info[:2] < (3, 8):
99
print('Error: Python 3.8 or higher is required', file=sys.stderr)
1010
sys.exit(1)
1111

12-
12+
import base64
1313
import json
1414
import os
1515

16-
from argparse import ArgumentParser, ArgumentTypeError
17-
from typing import Any, Callable
16+
from argparse import _SubParsersAction, ArgumentParser, ArgumentTypeError
17+
from typing import Any, Callable, TypedDict
1818
from urllib.error import HTTPError, URLError
1919
from urllib.parse import urlparse
2020
from urllib.request import Request, urlopen
@@ -38,10 +38,11 @@ Features:
3838
- Store and retrieve configuration (e.g., base URL)
3939
"""
4040

41-
USER_CONFIG_FILEPATH = os.path.expanduser('~/.odc-cli.json')
42-
4341
config: Config # initialized in main
4442

43+
# --- Types ---
44+
45+
JwtPayload = TypedDict('JwtPayload', {'basePermissionLevel': str})
4546

4647
# --- Decorators ---
4748

@@ -51,7 +52,20 @@ def require_url(fn: Callable[..., Any]) -> Callable[..., Any]:
5152

5253
def wrapper(*args: Any, **kwargs: Any) -> None:
5354
if config.base_url is None:
54-
raise RuntimeError(f'URL must be defined (hint: use {PROGRAM_NAME} config set-url)')
55+
print(f'Error: URL must be defined (hint: use {PROGRAM_NAME} config set-url)', file=sys.stderr)
56+
sys.exit(1)
57+
fn(*args, **kwargs)
58+
59+
return wrapper
60+
61+
62+
def require_token(fn: Callable[..., Any]) -> Callable[..., Any]:
63+
"""Decorator to ensure that the user is logged in before executing the function"""
64+
65+
def wrapper(*args: Any, **kwargs: Any) -> None:
66+
if config.access_token is None:
67+
print(f'Error: Must be logged in (hint: use {PROGRAM_NAME} auth login)', file=sys.stderr)
68+
sys.exit(1)
5569
fn(*args, **kwargs)
5670

5771
return wrapper
@@ -61,6 +75,8 @@ def require_url(fn: Callable[..., Any]) -> Callable[..., Any]:
6175

6276

6377
class ArgumentTypes:
78+
"""Static methods to validate cli args"""
79+
6480
@staticmethod
6581
def valid_url_or_none(url: str) -> str | None:
6682
if url == 'None':
@@ -71,8 +87,31 @@ class ArgumentTypes:
7187
return url
7288

7389

90+
class JWTDecoder:
91+
"""Decodes the payload of a JSON Web Token (JWT) without validating its signature"""
92+
93+
@classmethod
94+
def decode_payload(cls, token: str) -> dict[str, Any]:
95+
try:
96+
parts = token.split('.')
97+
if len(parts) != 3:
98+
raise ValueError('Invalid JWT format: expected header.payload.signature')
99+
payload_b64 = parts[1]
100+
decoded_bytes = cls._base64url_decode(payload_b64)
101+
payload = json.loads(decoded_bytes.decode('utf-8'))
102+
return payload
103+
except Exception as e:
104+
raise RuntimeError('Failed to decode JWT payload') from e
105+
106+
@staticmethod
107+
def _base64url_decode(input_str: str):
108+
padding = '=' * (-len(input_str) % 4)
109+
input_str += padding
110+
return base64.urlsafe_b64decode(input_str)
111+
112+
74113
class Config:
75-
"""Manages user configuration stored in a JSON file"""
114+
"""Manages program configuration stored in a JSON file"""
76115

77116
_dict: dict[str, Any]
78117
_filepath = os.path.expanduser('~/.odc-cli.json')
@@ -99,6 +138,8 @@ class Config:
99138
@base_url.setter
100139
def base_url(self, value: str | None) -> None:
101140
self._dict['base_url'] = value
141+
if value is None:
142+
self._dict['access_token'] = None
102143

103144
def write(self) -> None:
104145
with open(self._filepath, 'w') as file:
@@ -133,11 +174,17 @@ class HttpResponse:
133174
class HttpClient:
134175
"""Simplified HTTP client for making requests"""
135176

177+
_default_headers: dict[str, str] = {'Content-Type': 'application/json'}
178+
179+
@classmethod
180+
def get(cls, url: str) -> HttpResponse:
181+
req = Request(url, headers=cls._default_headers, method='GET')
182+
return cls._send(req)
183+
136184
@classmethod
137185
def post(cls, url: str, data: Any) -> HttpResponse:
138-
headers: dict[str, str] = {'Content-Type': 'application/json'}
139186
json_data = json.dumps(data).encode('utf-8')
140-
req = Request(url, data=json_data, headers=headers, method='POST')
187+
req = Request(url, data=json_data, headers=cls._default_headers, method='POST')
141188
return cls._send(req)
142189

143190
@staticmethod
@@ -187,6 +234,7 @@ class AuthCommands:
187234
return None
188235
config.access_token = response.data['accessToken']
189236
print('Success!')
237+
config.write()
190238

191239

192240
class ConfigCommands:
@@ -199,41 +247,81 @@ class ConfigCommands:
199247
config.base_url = url
200248
config.write()
201249

250+
@require_url
251+
@require_token
252+
@staticmethod
253+
def get_permissions() -> None:
254+
assert config.access_token is not None
255+
payload = JWTDecoder.decode_payload(config.access_token)
256+
permissions = payload.get('permissions')
257+
assert isinstance(permissions, list)
258+
print(json.dumps(permissions, indent=2))
259+
260+
261+
class SetupCommands:
262+
@require_url
263+
@staticmethod
264+
def get_status() -> None:
265+
response = HttpClient.get(f'{config.base_url}/v1/setup')
266+
print(response)
267+
202268

203269
# --- Main CLI Entrypoint ---
204270

205271

206-
def main() -> None:
207-
global config
208-
config = Config()
272+
class CLI:
273+
parser: ArgumentParser
274+
subparsers: _SubParsersAction[ArgumentParser]
275+
276+
def __init__(self) -> None:
277+
self.parser = ArgumentParser(prog=PROGRAM_NAME, description=PROGRAM_DESCRIPTION)
278+
self.subparsers = self.parser.add_subparsers(help='subcommand help', required=True)
279+
self._create_auth_parser()
280+
self._create_config_parser()
281+
self._create_setup_parser()
282+
283+
def run(self) -> None:
284+
kwargs = vars(self.parser.parse_args())
285+
fn = kwargs.pop('fn')
286+
fn(**kwargs)
287+
288+
def _create_auth_parser(self):
289+
parser = self.subparsers.add_parser('auth')
290+
subparsers = parser.add_subparsers(required=True)
209291

210-
parser = ArgumentParser(prog=PROGRAM_NAME, description=PROGRAM_DESCRIPTION)
211-
subparsers = parser.add_subparsers(help='subcommand help', required=True)
292+
login_parser = subparsers.add_parser('login')
293+
login_parser.add_argument('--username', type=str, required=True)
294+
login_parser.add_argument('--password', type=str, required=True)
295+
login_parser.set_defaults(fn=AuthCommands.login)
212296

213-
## AUTH
214-
auth_parser = subparsers.add_parser('auth')
215-
auth_subparsers = auth_parser.add_subparsers(required=True)
297+
def _create_config_parser(self):
298+
parser = self.subparsers.add_parser('config')
299+
subparsers = parser.add_subparsers(required=True)
216300

217-
login_parser = auth_subparsers.add_parser('login')
218-
login_parser.add_argument('--username', type=str, required=True)
219-
login_parser.add_argument('--password', type=str, required=True)
220-
login_parser.set_defaults(fn=AuthCommands.login)
301+
get_config_url_parser = subparsers.add_parser('get-url')
302+
get_config_url_parser.set_defaults(fn=ConfigCommands.get_url)
221303

222-
## CONFIG
304+
set_config_parser = subparsers.add_parser('set-url')
305+
set_config_parser.add_argument('url', type=ArgumentTypes.valid_url_or_none)
306+
set_config_parser.set_defaults(fn=ConfigCommands.set_url)
223307

224-
config_parser = subparsers.add_parser('config')
225-
config_subparsers = config_parser.add_subparsers(required=True)
308+
get_permissions_parser = subparsers.add_parser('get-permissions')
309+
get_permissions_parser.set_defaults(fn=ConfigCommands.get_permissions)
226310

227-
get_config_url_parser = config_subparsers.add_parser('get-url')
228-
get_config_url_parser.set_defaults(fn=ConfigCommands.get_url)
311+
def _create_setup_parser(self):
312+
setup_parser = self.subparsers.add_parser('setup')
313+
setup_subparsers = setup_parser.add_subparsers(required=True)
229314

230-
set_config_parser = config_subparsers.add_parser('set-url')
231-
set_config_parser.add_argument('url', type=ArgumentTypes.valid_url_or_none)
232-
set_config_parser.set_defaults(fn=ConfigCommands.set_url)
315+
get_setup_state_parser = setup_subparsers.add_parser('get-state')
316+
get_setup_state_parser.set_defaults(fn=SetupCommands.get_status)
317+
318+
319+
def main() -> None:
320+
global config
321+
config = Config()
233322

234-
kwargs = vars(parser.parse_args())
235-
fn = kwargs.pop('fn')
236-
fn(**kwargs)
323+
cli = CLI()
324+
cli.run()
237325

238326

239327
if __name__ == '__main__':

0 commit comments

Comments
 (0)