Skip to content

Commit 967984d

Browse files
authored
Merge pull request #1154 from joshunrau/cli-base
feat: add open-data-capture cli tool
2 parents 23db8cd + 170ad5b commit 967984d

File tree

1 file changed

+203
-0
lines changed

1 file changed

+203
-0
lines changed

cli/open-data-capture

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
#!/usr/bin/env python3
2+
3+
from __future__ import annotations
4+
5+
import sys
6+
7+
if sys.version_info[:2] < (3, 8):
8+
print('Error: Python 3.8 or higher is required', file=sys.stderr)
9+
sys.exit(1)
10+
11+
12+
import json
13+
import os
14+
15+
from argparse import ArgumentTypeError, ArgumentParser
16+
from typing import Any, Callable
17+
from urllib.parse import urlparse
18+
from urllib.request import HTTPError, Request, urlopen
19+
from urllib.response import addinfourl
20+
from urllib.error import URLError
21+
22+
PROGRAM_NAME = os.path.basename(sys.argv[0])
23+
USER_CONFIG_FILEPATH = os.path.expanduser('~/.odc-cli.json')
24+
25+
26+
config: Config
27+
28+
29+
def require_url(fn: Callable[..., Any]) -> Callable[..., Any]:
30+
def wrapper(*args: Any, **kwargs: Any) -> None:
31+
if config.base_url is None:
32+
raise RuntimeError(f'URL must be defined (hint: use {PROGRAM_NAME} config set-url)')
33+
fn(*args, **kwargs)
34+
35+
return wrapper
36+
37+
38+
class ArgumentTypes:
39+
@staticmethod
40+
def valid_url_or_null(url: str) -> str | None:
41+
if url == 'null':
42+
return None
43+
parsed = urlparse(url)
44+
if not all([parsed.scheme, parsed.netloc]):
45+
raise ArgumentTypeError(f'invalid url: {url}')
46+
return url
47+
48+
49+
class Config:
50+
_dict: dict[str, Any]
51+
_filepath = os.path.expanduser('~/.odc-cli.json')
52+
53+
def __init__(self) -> None:
54+
if not os.path.exists(self._filepath):
55+
self._dict = {}
56+
return None
57+
with open(self._filepath, 'r') as file:
58+
self._dict = json.load(file)
59+
60+
@property
61+
def access_token(self) -> str | None:
62+
return self._dict.get('access_token')
63+
64+
@access_token.setter
65+
def access_token(self, value: str | None) -> None:
66+
self._dict['access_token'] = value
67+
68+
@property
69+
def base_url(self) -> str | None:
70+
return self._dict.get('base_url')
71+
72+
@base_url.setter
73+
def base_url(self, value: str | None) -> None:
74+
self._dict['base_url'] = value
75+
76+
def write(self) -> None:
77+
with open(self._filepath, 'w') as file:
78+
json.dump(self._dict, file)
79+
80+
81+
class HttpResponse:
82+
def __init__(self, data: Any, status: int | None):
83+
self.data = data
84+
self.status = status
85+
86+
def __str__(self) -> str:
87+
lines = [
88+
f'HTTP Status: {self.status if self.status is not None else "N/A"}',
89+
f'Success: {self.ok}',
90+
'Data:',
91+
]
92+
try:
93+
pretty_data = json.dumps(self.data, indent=2, ensure_ascii=False)
94+
except (TypeError, ValueError):
95+
pretty_data = str(self.data)
96+
lines.append(pretty_data)
97+
return '\n'.join(lines)
98+
99+
@property
100+
def ok(self) -> bool:
101+
return self.status is not None and 200 <= self.status < 300
102+
103+
104+
class HttpClient:
105+
@classmethod
106+
def post(cls, url: str, data: Any) -> HttpResponse:
107+
headers: dict[str, str] = {'Content-Type': 'application/json'}
108+
json_data = json.dumps(data).encode('utf-8')
109+
req = Request(url, data=json_data, headers=headers, method='POST')
110+
return cls._send(req)
111+
112+
@staticmethod
113+
def _send(req: Request) -> HttpResponse:
114+
try:
115+
with urlopen(req, timeout=2) as response:
116+
return HttpClient._build_response(response)
117+
except HTTPError as err:
118+
return HttpClient._build_response(err)
119+
except URLError as err:
120+
reason = err.reason
121+
if isinstance(reason, OSError):
122+
message = f'Network error: {reason.strerror or str(reason)}'
123+
else:
124+
message = f'Failed to reach the server: {reason}'
125+
126+
return HttpResponse(
127+
data={
128+
'error': 'URLError',
129+
'message': message,
130+
'hint': 'Please check if the URL is correct and the server is reachable',
131+
'url': req.get_full_url(),
132+
},
133+
status=None,
134+
)
135+
136+
@staticmethod
137+
def _build_response(response: addinfourl) -> HttpResponse:
138+
body = response.read()
139+
try:
140+
data = json.loads(body.decode(errors='replace'))
141+
except json.JSONDecodeError:
142+
data = {'error': 'Invalid JSON response'}
143+
return HttpResponse(data=data, status=response.getcode())
144+
145+
146+
class AuthCommands:
147+
@require_url
148+
@staticmethod
149+
def login(username: str, password: str) -> None:
150+
response = HttpClient.post(f'{config.base_url}/v1/auth/login', {'username': username, 'password': password})
151+
if not response.ok:
152+
print(response)
153+
return None
154+
config.access_token = response.data['accessToken']
155+
print('Success!')
156+
157+
158+
class ConfigCommands:
159+
@staticmethod
160+
def get_url() -> None:
161+
print(config.base_url or 'null')
162+
163+
@staticmethod
164+
def set_url(url: str) -> None:
165+
config.base_url = url
166+
config.write()
167+
168+
169+
def main() -> None:
170+
global config
171+
config = Config()
172+
173+
parser = ArgumentParser(prog=PROGRAM_NAME)
174+
subparsers = parser.add_subparsers(help='subcommand help', required=True)
175+
176+
## AUTH
177+
auth_parser = subparsers.add_parser('auth')
178+
auth_subparsers = auth_parser.add_subparsers(required=True)
179+
180+
login_parser = auth_subparsers.add_parser('login')
181+
login_parser.add_argument('--username', type=str, required=True)
182+
login_parser.add_argument('--password', type=str, required=True)
183+
login_parser.set_defaults(fn=AuthCommands.login)
184+
185+
## CONFIG
186+
187+
config_parser = subparsers.add_parser('config')
188+
config_subparsers = config_parser.add_subparsers(required=True)
189+
190+
get_config_url_parser = config_subparsers.add_parser('get-url')
191+
get_config_url_parser.set_defaults(fn=ConfigCommands.get_url)
192+
193+
set_config_parser = config_subparsers.add_parser('set-url')
194+
set_config_parser.add_argument('url', type=ArgumentTypes.valid_url_or_null)
195+
set_config_parser.set_defaults(fn=ConfigCommands.set_url)
196+
197+
kwargs = vars(parser.parse_args())
198+
fn = kwargs.pop('fn')
199+
fn(**kwargs)
200+
201+
202+
if __name__ == '__main__':
203+
main()

0 commit comments

Comments
 (0)