twc-cli 1.0.0rc0__py3-none-any.whl

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.

Potentially problematic release.


This version of twc-cli might be problematic. Click here for more details.

@@ -0,0 +1,286 @@
1
+ """TWC CLI commands package.
2
+
3
+ __init__.py module contains common functions, decorators and variables.
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ import logging
9
+
10
+ import click
11
+ import toml
12
+
13
+ from twc.__version__ import __version__, __pyversion__
14
+ from twc.api import TimewebCloud
15
+ from twc.api import (
16
+ UnauthorizedError,
17
+ NonJSONResponseError,
18
+ BadResponseError,
19
+ UnexpectedResponseError,
20
+ )
21
+
22
+
23
+ # -----------------------------------------------------------------------
24
+ # Configuration
25
+
26
+
27
+ USER_AGENT = f"TWC-CLI/{__version__} Python {__pyversion__}"
28
+ DEFAULT_CONFIG = {"default": {"token": ""}}
29
+ DEFAULT_CONFIGURATOR_ID = 11
30
+ REGIONS_WITH_CONFIGURATOR = ["ru-1"]
31
+ REGIONS_WITH_IPV6 = ["ru-1"]
32
+
33
+
34
+ def get_default_config_file() -> str:
35
+ if os.name == "nt":
36
+ env_home = "USERPROFILE"
37
+ else:
38
+ env_home = "HOME"
39
+ return os.path.join(os.getenv(env_home), ".twcrc")
40
+
41
+
42
+ def load_config(filepath: str = get_default_config_file()):
43
+ """Load configuration from TOML config file."""
44
+ try:
45
+ with open(filepath, "r", encoding="utf-8") as file:
46
+ return toml.load(file)
47
+ except (OSError, FileNotFoundError) as error:
48
+ sys.exit(f"Error: {filepath}: {error}: Try run 'twc config'")
49
+ except toml.TomlDecodeError as error:
50
+ sys.exit(f"Error: {filepath}: {error}")
51
+
52
+
53
+ def set_value_from_config(ctx, param, value):
54
+ """Callback for Click to load option values from configuration file."""
55
+ if value is None:
56
+ if not os.path.exists(ctx.params["config"]):
57
+ return None
58
+ try:
59
+ value = load_config(ctx.params["config"])[ctx.params["profile"]][
60
+ param.name
61
+ ]
62
+ except KeyError:
63
+ return None
64
+ return value
65
+
66
+
67
+ # -----------------------------------------------------------------------
68
+ # Logger
69
+
70
+
71
+ def set_logger(ctx, param, value):
72
+ """Click callback for '--verbose' option. Set logger settings.
73
+ Log HTTP requests and anything else.
74
+ """
75
+ if value:
76
+ logging.basicConfig(
77
+ level=logging.DEBUG,
78
+ format="%(asctime)s:%(levelname)s:%(name)s: %(message)s",
79
+ datefmt="%Y-%m-%dT%H:%M:%S%z",
80
+ )
81
+
82
+
83
+ def log(message: str, *args, **kwargs):
84
+ """Print DEBUG log message"""
85
+ logging.debug(message, *args, **kwargs)
86
+
87
+
88
+ def log_request(response: object):
89
+ """Log data from `requests.PreparedRequest` and `requests.Response`
90
+ objects. Hide auth Bearer token.
91
+ """
92
+ request_headers = response.request.headers
93
+ request_headers.update(
94
+ {"Authorization": "Bearer <SENSITIVE_DATA_DELETED>"}
95
+ )
96
+
97
+ for key in list(request_headers.keys()):
98
+ log(f"Request header: {key}: {request_headers[key]}")
99
+
100
+ if response.request.method in ["POST", "PUT", "PATCH"]:
101
+ log(f"Request body (raw): {response.request.body}")
102
+
103
+ for key in list(response.headers.keys()):
104
+ log(f"Response header: {key}: {response.headers[key]}")
105
+
106
+
107
+ # -----------------------------------------------------------------------
108
+ # CLI
109
+
110
+
111
+ GLOBAL_OPTIONS = [
112
+ click.help_option("--help"),
113
+ click.version_option(__version__, "--version", prog_name="twc"),
114
+ click.option(
115
+ "--verbose",
116
+ "-v",
117
+ is_flag=True,
118
+ envvar="TWC_DEBUG",
119
+ callback=set_logger,
120
+ help="Enable verbose mode.",
121
+ ),
122
+ click.option(
123
+ "--config",
124
+ "-c",
125
+ metavar="FILE",
126
+ envvar="TWC_CONFIG_FILE",
127
+ default=get_default_config_file(),
128
+ is_eager=True,
129
+ show_default=True,
130
+ help="Use config file.",
131
+ ),
132
+ click.option(
133
+ "--profile",
134
+ "-p",
135
+ envvar="TWC_PROFILE",
136
+ default="default",
137
+ show_default=True,
138
+ is_eager=True,
139
+ help="Use profile.",
140
+ ),
141
+ ]
142
+
143
+ OUTPUT_FORMAT_OPTION = [
144
+ click.option(
145
+ "--output",
146
+ "-o",
147
+ "output_format",
148
+ type=click.Choice(["default", "raw", "json", "yaml"]),
149
+ envvar="TWC_OUTPUT_FORMAT",
150
+ callback=set_value_from_config,
151
+ help="Output format.",
152
+ )
153
+ ]
154
+
155
+
156
+ def options(options_list: list):
157
+ """Add multiple options to command."""
158
+
159
+ def wrapper(func):
160
+ for option in reversed(options_list):
161
+ func = option(func)
162
+ return func
163
+
164
+ return wrapper
165
+
166
+
167
+ class MutuallyExclusiveOption(click.Option):
168
+ """Add mutually exclusive options support for Click. Example::
169
+
170
+ @click.option(
171
+ "--dry",
172
+ is_flag=True,
173
+ cls=MutuallyExclusiveOption,
174
+ mutually_exclusive=["wet"],
175
+ )
176
+ @click.option("--wet", is_flag=True)
177
+ """
178
+
179
+ def __init__(self, *args, **kwargs):
180
+ self.mutually_exclusive = set(kwargs.pop("mutually_exclusive", []))
181
+ help = kwargs.get("help", "") # pylint: disable=redefined-builtin
182
+ if self.mutually_exclusive:
183
+ kwargs["help"] = help + (
184
+ " NOTE: This argument is mutually exclusive with "
185
+ f"arguments: [{', '.join(self.mutually_exclusive)}]."
186
+ )
187
+ super().__init__(*args, **kwargs)
188
+
189
+ def handle_parse_result(self, ctx, opts, args):
190
+ if self.mutually_exclusive.intersection(opts) and self.name in opts:
191
+ raise click.UsageError(
192
+ f"Illegal usage: '{self.name}' is mutually exclusive with "
193
+ f"arguments: [{', '.join(self.mutually_exclusive)}]."
194
+ )
195
+ return super().handle_parse_result(ctx, opts, args)
196
+
197
+
198
+ def confirm_action(question: str, default: str = "no"):
199
+ """Ask a yes/no question via input() and return their answer.
200
+
201
+ The "answer" return value is True for "yes" or False for "no".
202
+ """
203
+ valid = {
204
+ "yes": True,
205
+ "y": True,
206
+ "ye": True,
207
+ "no": False,
208
+ "n": False,
209
+ }
210
+ if default is None:
211
+ prompt = "[y/n]"
212
+ elif default == "yes":
213
+ prompt = "[Y/n]"
214
+ elif default == "no":
215
+ prompt = "[y/N]"
216
+ else:
217
+ raise ValueError(f"Invalid default answer: '{default}'")
218
+
219
+ while True:
220
+ choice = input(f"{question} {prompt}: ").lower()
221
+ if default is not None and choice == "":
222
+ return valid[default]
223
+ if choice in valid:
224
+ return valid[choice]
225
+ sys.exit("Please respond with 'yes' or 'no' (or 'y' or 'n').")
226
+
227
+
228
+ # -----------------------------------------------------------------------
229
+ # API interaction
230
+
231
+
232
+ def handle_request(func):
233
+ def wrapper(*args, **kwargs):
234
+ try:
235
+ response = func(*args, **kwargs)
236
+
237
+ log_request(response)
238
+ if not response.text:
239
+ log("No response body")
240
+
241
+ if response.status_code not in [200, 201, 204]:
242
+ raise BadResponseError
243
+ except UnauthorizedError:
244
+ print(
245
+ "Error: Unauthorized. "
246
+ + "Please check your API access token. Try run 'twc config'",
247
+ file=sys.stderr,
248
+ )
249
+ sys.exit(1)
250
+ except BadResponseError:
251
+ resp = response.json()
252
+ print("Error occurred. Details:", file=sys.stderr)
253
+ print(f"Status code: {resp['status_code']}", file=sys.stderr)
254
+ print(f" Error code: {resp['error_code']}", file=sys.stderr)
255
+ if isinstance(resp["message"], list):
256
+ for message in resp["message"]:
257
+ print(f" Message: {message}", file=sys.stderr)
258
+ else:
259
+ print(f" Message: {resp['message']}", file=sys.stderr)
260
+ print(f"Response ID: {resp['response_id']}", file=sys.stderr)
261
+ sys.exit(1)
262
+ except (NonJSONResponseError, UnexpectedResponseError) as error:
263
+ sys.exit(f"Error: {error}")
264
+ return response
265
+
266
+ return wrapper
267
+
268
+
269
+ def create_client(config, profile):
270
+ """Create API client instance with access token."""
271
+
272
+ log(f"TWC CLI {__version__} Python {__pyversion__}")
273
+ log(f"Args: {sys.argv[1:]}")
274
+
275
+ env_token = os.getenv("TWC_TOKEN")
276
+
277
+ if env_token:
278
+ log("Get Timeweb Cloud token from environment variable")
279
+ return TimewebCloud(env_token, user_agent=USER_AGENT)
280
+
281
+ try:
282
+ log(f"Configuration: config file={config}; profile={profile}")
283
+ token = load_config(config)[profile]["token"]
284
+ return TimewebCloud(token, user_agent=USER_AGENT)
285
+ except KeyError:
286
+ sys.exit(f"Profile '{profile}' not found in {config}")
@@ -0,0 +1,178 @@
1
+ """Account management commands."""
2
+
3
+ import click
4
+
5
+ from twc import fmt
6
+ from . import (
7
+ create_client,
8
+ handle_request,
9
+ options,
10
+ GLOBAL_OPTIONS,
11
+ OUTPUT_FORMAT_OPTION,
12
+ )
13
+
14
+
15
+ @handle_request
16
+ def _account_finances(client):
17
+ return client.get_account_finances()
18
+
19
+
20
+ @handle_request
21
+ def _account_status(client):
22
+ return client.get_account_status()
23
+
24
+
25
+ @handle_request
26
+ def _restrictions_status(client):
27
+ return client.get_account_restrictions()
28
+
29
+
30
+ # ------------------------------------------------------------- #
31
+ # $ twc account #
32
+ # ------------------------------------------------------------- #
33
+
34
+
35
+ @click.group()
36
+ @options(GLOBAL_OPTIONS[:2])
37
+ def account():
38
+ """Manage Timeweb Cloud account."""
39
+
40
+
41
+ # ------------------------------------------------------------- #
42
+ # $ twc account status #
43
+ # ------------------------------------------------------------- #
44
+
45
+
46
+ def print_account_status(response: object):
47
+ table = fmt.Table()
48
+ status = response.json()["status"]
49
+ translated_keys = {
50
+ "company_info": "Company",
51
+ "ym_client_id": "Yandex.Metrika client ID",
52
+ "is_blocked": "Blocked",
53
+ "is_permanent_blocked": "Permanently blocked",
54
+ "is_send_bill_letters": "Send bill emails",
55
+ "last_password_changed_at": "Password changed at",
56
+ }
57
+ for key in status.keys():
58
+ try:
59
+ if key == "company_info":
60
+ table.row([translated_keys[key], ":", status[key]["name"]])
61
+ else:
62
+ table.row([translated_keys[key], ":", status[key]])
63
+ except KeyError:
64
+ pass
65
+ table.print()
66
+
67
+
68
+ @account.command("status", help="Get account status.")
69
+ @options(GLOBAL_OPTIONS)
70
+ @options(OUTPUT_FORMAT_OPTION)
71
+ def account_status(config, profile, verbose, output_format):
72
+ client = create_client(config, profile)
73
+ response = _account_status(client)
74
+ fmt.printer(
75
+ response, output_format=output_format, func=print_account_status
76
+ )
77
+
78
+
79
+ # ------------------------------------------------------------- #
80
+ # $ twc account finances #
81
+ # ------------------------------------------------------------- #
82
+
83
+
84
+ def print_account_finances(response: object):
85
+ table = fmt.Table()
86
+ finances = response.json()["finances"]
87
+ translated_keys = {
88
+ "balance": "Balance",
89
+ "currency": "Currency",
90
+ "discount_end_date_at": "Discount ends at",
91
+ "discount_percent": "Discount",
92
+ "hourly_cost": "Hourly cost",
93
+ "hourly_fee": "Hourly fee",
94
+ "monthly_cost": "Monthly cost",
95
+ "monthly_fee": "Monthly fee",
96
+ "total_paid": "Total paid",
97
+ "hours_left": "Hours left",
98
+ "autopay_card_info": "Autopay Card",
99
+ }
100
+ for key in finances.keys():
101
+ try:
102
+ table.row([translated_keys[key], ":", finances[key]])
103
+ except KeyError:
104
+ pass
105
+ table.print()
106
+
107
+
108
+ @account.command("finances", help="Get finances.")
109
+ @options(GLOBAL_OPTIONS)
110
+ @options(OUTPUT_FORMAT_OPTION)
111
+ def account_finances(config, profile, verbose, output_format):
112
+ client = create_client(config, profile)
113
+ response = _account_finances(client)
114
+ fmt.printer(
115
+ response, output_format=output_format, func=print_account_finances
116
+ )
117
+
118
+
119
+ # ------------------------------------------------------------- #
120
+ # $ twc account access #
121
+ # ------------------------------------------------------------- #
122
+
123
+
124
+ @account.group()
125
+ @options(GLOBAL_OPTIONS[:2])
126
+ def access():
127
+ """Manage account access restrictions."""
128
+
129
+
130
+ # ------------------------------------------------------------- #
131
+ # $ twc account access restrictions #
132
+ # ------------------------------------------------------------- #
133
+
134
+
135
+ def print_restrictions_status(response: object, by_ip: bool, by_country: bool):
136
+ restrictions = response.json()
137
+
138
+ if not by_ip and not by_country:
139
+ by_ip = by_country = True
140
+
141
+ if by_ip:
142
+ if restrictions["is_ip_restrictions_enabled"]:
143
+ click.echo("IP restrictions: enabled")
144
+ click.echo("Allowed IPs:")
145
+ for ip_addr in restrictions["white_list"]["ips"]:
146
+ click.echo(f" - {ip_addr}")
147
+ else:
148
+ click.echo("IP restrictions: disabled")
149
+
150
+ if by_country:
151
+ if restrictions["is_country_restrictions_enabled"]:
152
+ click.echo("Country restrictions: enabled")
153
+ click.echo("Allowed countries:")
154
+ for country in restrictions["white_list"]["countries"]:
155
+ click.echo(f" - {country}")
156
+ else:
157
+ click.echo("Country restrictions: disabled")
158
+
159
+
160
+ @access.command("restrictions", help="View access restrictions status.")
161
+ @options(GLOBAL_OPTIONS)
162
+ @options(OUTPUT_FORMAT_OPTION)
163
+ @click.option("--by-ip", is_flag=True, help="Display IP restrictions.")
164
+ @click.option(
165
+ "--by-country", is_flag=True, help="Display country restrictions."
166
+ )
167
+ def restrictions_status(
168
+ config, profile, verbose, output_format, by_ip, by_country
169
+ ):
170
+ client = create_client(config, profile)
171
+ response = _restrictions_status(client)
172
+ fmt.printer(
173
+ response,
174
+ output_format=output_format,
175
+ func=print_restrictions_status,
176
+ by_ip=by_ip,
177
+ by_country=by_country,
178
+ )
twc/commands/config.py ADDED
@@ -0,0 +1,46 @@
1
+ """CLI configuration."""
2
+
3
+ import os
4
+ import sys
5
+ import ctypes
6
+
7
+ import toml
8
+ import click
9
+
10
+ from . import options, get_default_config_file, GLOBAL_OPTIONS, DEFAULT_CONFIG
11
+
12
+
13
+ def make_config(filepath: str = get_default_config_file()):
14
+ """Create new configuration file."""
15
+ if os.path.exists(filepath):
16
+ sys.exit(f"File '{filepath}' already exists.")
17
+ else:
18
+ click.echo("Create new configuration file. Enter your API token.")
19
+ while True:
20
+ token = input("Token: ")
21
+ if token:
22
+ DEFAULT_CONFIG.update({"default": {"token": token}})
23
+ break
24
+ click.echo("Please enter token. Press ^C to cancel.")
25
+ try:
26
+ with open(filepath, "w", encoding="utf-8") as file:
27
+ toml.dump(DEFAULT_CONFIG, file)
28
+ if os.name == "nt":
29
+ hidden_file_attr = 0x02
30
+ ctypes.windll.kernel32.SetFileAttributesW(
31
+ filepath, hidden_file_attr
32
+ )
33
+ click.echo(f"Success! Configuration is saved in {filepath}")
34
+ except OSError as error:
35
+ sys.exit(f"Error: {error}")
36
+
37
+
38
+ # ------------------------------------------------------------- #
39
+ # $ twc config #
40
+ # ------------------------------------------------------------- #
41
+
42
+
43
+ @click.command("config", help="Make new configuration file.")
44
+ @options(GLOBAL_OPTIONS[:2])
45
+ def config():
46
+ make_config()