twc-cli 1.2.0__tar.gz → 1.3.0__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.

Potentially problematic release.


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

@@ -2,6 +2,18 @@
2
2
 
3
3
  В этом файле описаны все значимые изменения в Timeweb Cloud CLI. В выпусках мы придерживается правил [семантического версионирования](https://semver.org/lang/ru/).
4
4
 
5
+ # Версия 1.3.0 (2023.04.13)
6
+
7
+ Добавлено:
8
+
9
+ - Добавлена команда `twc storage` (алиас `twc s3`) для управления бакетами в объектном хранилище Timeweb Cloud. Доступны базовые операции с бакетами. Также реализованы субкоманды для работы с доменами и пользователями хранилища. Обратите внимание, что в twc не планируется добавлять реализацию S3-клиента, пользуйтесь любым совместимым клиентом, например, [s3cmd](https://s3tools.org/s3cmd) или [rclone](https://rclone.org/), команда `twc s3 genconfig` позволит сгенерировать конфигурационный файл для этих утилит (см. документацию).
10
+ - Добавлены субкоманды для `twc config`. Теперь можно: сделать дамп настроек CLI (`twc config dump`), изменять настройки без ручного редактирования файла (`twc config set`), получить путь до файла (`twc config file`), открыть файл в редакторе по умолчанию (`twc config edit`).
11
+
12
+ Изменено:
13
+
14
+ - Изменены тексты ошибок при работе с конфигурационным файлом, теперь они более наглядные и явно указывают на проблему.
15
+ - Изменено поведение команды `twc config`. Теперь если уже иммется конфигурационный файл команда предложит добавить новый профиль или отредактировать существующий.
16
+
5
17
  # Версия 1.2.0 (2023.04.03)
6
18
 
7
19
  Добавлено:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: twc-cli
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Timeweb Cloud Command Line Interface.
5
5
  Home-page: https://github.com/timeweb-cloud/twc
6
6
  License: MIT
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.10
15
15
  Classifier: Programming Language :: Python :: 3.11
16
16
  Requires-Dist: click (>=8.1.3,<9.0.0)
17
17
  Requires-Dist: click-aliases (>=1.0.1,<2.0.0)
18
+ Requires-Dist: click-default-group (>=1.2.2,<2.0.0)
18
19
  Requires-Dist: pygments (>=2.14.0,<3.0.0)
19
20
  Requires-Dist: pyyaml (>=6.0,<7.0)
20
21
  Requires-Dist: requests (>=2.28.1,<3.0.0)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "twc-cli"
3
- version = "1.2.0"
3
+ version = "1.3.0"
4
4
  description = "Timeweb Cloud Command Line Interface."
5
5
  authors = ["ge <dev@timeweb.cloud>"]
6
6
  homepage = "https://github.com/timeweb-cloud/twc"
@@ -20,6 +20,7 @@ click = "^8.1.3"
20
20
  click-aliases = "^1.0.1"
21
21
  toml = "^0.10.2"
22
22
  pyyaml = "^6.0"
23
+ click-default-group = "^1.2.2"
23
24
 
24
25
  [tool.poetry.group.dev.dependencies]
25
26
  black = "^22.12.0"
@@ -12,6 +12,7 @@ from .commands.ssh_key import ssh_key
12
12
  from .commands.image import image
13
13
  from .commands.project import project
14
14
  from .commands.database import database
15
+ from .commands.storage import storage
15
16
 
16
17
 
17
18
  class AliasedCmdGroup(click.Group):
@@ -44,7 +45,7 @@ class AliasedCmdGroup(click.Group):
44
45
  continue
45
46
  commands.append((subcommand, cmd))
46
47
 
47
- if len(commands):
48
+ if commands:
48
49
  limit = formatter.width - 6 - max(len(cmd[0]) for cmd in commands)
49
50
  rows = []
50
51
  for subcommand, cmd in commands:
@@ -53,8 +54,8 @@ class AliasedCmdGroup(click.Group):
53
54
  for a in list(ALIASES.keys()):
54
55
  if ALIASES[a].name == cmd.name:
55
56
  alias = f" ({a})"
56
- help = cmd.get_short_help_str(limit)
57
- rows.append((subcommand + alias, help))
57
+ help_text = cmd.get_short_help_str(limit)
58
+ rows.append((subcommand + alias, help_text))
58
59
  if rows:
59
60
  with formatter.section(_("Commands")):
60
61
  formatter.write_dl(rows)
@@ -73,6 +74,7 @@ cli.add_command(ssh_key)
73
74
  cli.add_command(image)
74
75
  cli.add_command(project)
75
76
  cli.add_command(database)
77
+ cli.add_command(storage)
76
78
 
77
79
 
78
80
  # -- Aliases list for root level commands. --
@@ -89,4 +91,6 @@ ALIASES = {
89
91
  "p": project,
90
92
  "dbs": database,
91
93
  "db": database,
94
+ "storages": storage,
95
+ "s3": storage,
92
96
  }
@@ -9,5 +9,5 @@
9
9
  import sys
10
10
 
11
11
 
12
- __version__ = "1.2.0"
12
+ __version__ = "1.3.0"
13
13
  __pyversion__ = sys.version.replace("\n", "")
@@ -32,14 +32,15 @@ def raise_exceptions(func):
32
32
  except AttributeError:
33
33
  is_json = False
34
34
 
35
- if status_code in [200, 201, 400, 403, 404, 409, 429, 500]:
35
+ # Remove 201 to avoid API bug (201 with no body)
36
+ if status_code in [200, 400, 403, 404, 409, 429, 500]:
36
37
  if is_json:
37
38
  return response # Success
38
39
  raise NonJSONResponseError(
39
40
  f"Code: {status_code}, Response body: {response.text}"
40
41
  )
41
42
 
42
- if status_code == 204:
43
+ if status_code in [201, 204]:
43
44
  return response # Success
44
45
 
45
46
  if status_code == 401:
@@ -985,3 +986,184 @@ class TimewebCloud(metaclass=TimewebCloudMeta):
985
986
  """Restore database backup."""
986
987
  url = f"{self.api_url}/dbs/{db_id}/backups/{backup_id}"
987
988
  return requests.put(url, headers=self.headers, timeout=self.timeout)
989
+
990
+ # -----------------------------------------------------------------------
991
+ # Object Storage
992
+
993
+ def get_storage_presets(self):
994
+ """Get storage presets list."""
995
+ url = f"{self.api_url}/presets/storages"
996
+ return requests.get(url, headers=self.headers, timeout=self.timeout)
997
+
998
+ def get_buckets(self):
999
+ """Get buckets list."""
1000
+ url = f"{self.api_url}/storages/buckets"
1001
+ return requests.get(url, headers=self.headers, timeout=self.timeout)
1002
+
1003
+ def create_bucket(
1004
+ self, name: str = None, preset_id: int = None, is_public: bool = False
1005
+ ):
1006
+ """Create storage bucket."""
1007
+ url = f"{self.api_url}/storages/buckets"
1008
+ self.headers.update({"Content-Type": "application/json"})
1009
+ if is_public:
1010
+ bucket_type = "public"
1011
+ else:
1012
+ bucket_type = "private"
1013
+ payload = {
1014
+ "name": name,
1015
+ "type": bucket_type,
1016
+ "preset_id": preset_id,
1017
+ }
1018
+ return requests.post(
1019
+ url,
1020
+ headers=self.headers,
1021
+ timeout=self.timeout,
1022
+ data=json.dumps(payload),
1023
+ )
1024
+
1025
+ def delete_bucket(
1026
+ self, bucket_id: int, delete_hash: str = None, code: int = None
1027
+ ):
1028
+ """Delete storage bucket."""
1029
+ url = f"{self.api_url}/storages/buckets/{bucket_id}"
1030
+ params = {}
1031
+ if delete_hash:
1032
+ params["hash"] = delete_hash
1033
+ if code:
1034
+ params["code"] = code
1035
+ return requests.delete(
1036
+ url, headers=self.headers, timeout=self.timeout, params=params
1037
+ )
1038
+
1039
+ def update_bucket(
1040
+ self, bucket_id: int, preset_id: int = None, is_public: bool = None
1041
+ ):
1042
+ """Update storage bucket."""
1043
+ url = f"{self.api_url}/storages/buckets/{bucket_id}"
1044
+ self.headers.update({"Content-Type": "application/json"})
1045
+ payload = {}
1046
+ if is_public is None:
1047
+ pass
1048
+ elif is_public is False:
1049
+ payload["bucket_type"] = "private"
1050
+ elif is_public is True:
1051
+ payload["bucket_type"] = "public"
1052
+ if preset_id:
1053
+ payload["preset_id"] = preset_id
1054
+ return requests.patch(
1055
+ url,
1056
+ headers=self.headers,
1057
+ timeout=self.timeout,
1058
+ data=json.dumps(payload),
1059
+ )
1060
+
1061
+ def get_storage_users(self):
1062
+ """Get storage users list."""
1063
+ url = f"{self.api_url}/storages/users"
1064
+ return requests.get(url, headers=self.headers, timeout=self.timeout)
1065
+
1066
+ def update_storage_user_secret(
1067
+ self, user_id: int = None, secret_key: str = None
1068
+ ):
1069
+ """Update storage user secret key."""
1070
+ url = f"{self.api_url}/storages/users/{user_id}"
1071
+ self.headers.update({"Content-Type": "application/json"})
1072
+ payload = {"secret_key": secret_key}
1073
+ return requests.patch(
1074
+ url,
1075
+ headers=self.headers,
1076
+ timeout=self.timeout,
1077
+ data=json.dumps(payload),
1078
+ )
1079
+
1080
+ def get_storage_transfer_status(self, bucket_id: int = None):
1081
+ """Get storage transfer status."""
1082
+ url = f"{self.api_url}/storages/buckets/{bucket_id}/transfer-status"
1083
+ return requests.get(url, headers=self.headers, timeout=self.timeout)
1084
+
1085
+ def start_storage_transfer(
1086
+ self,
1087
+ src_bucket: str = None,
1088
+ dst_bucket: str = None,
1089
+ access_key: str = None,
1090
+ secret_key: str = None,
1091
+ location: str = None,
1092
+ endpoint: str = None,
1093
+ force_path_style: bool = False,
1094
+ ):
1095
+ """Start file transfer from any S3-compatible storage to Timeweb Cloud
1096
+ Object Storage.
1097
+ """
1098
+ url = f"{self.api_url}/storages/transfer"
1099
+ self.headers.update({"Content-Type": "application/json"})
1100
+ payload = {
1101
+ "access_key": access_key,
1102
+ "secret_key": secret_key,
1103
+ "location": location,
1104
+ "is_force_path_style": force_path_style,
1105
+ "endpoint": endpoint,
1106
+ "bucket_name": src_bucket,
1107
+ "new_bucket_name": dst_bucket,
1108
+ }
1109
+ return requests.post(
1110
+ url,
1111
+ headers=self.headers,
1112
+ timeout=self.timeout,
1113
+ data=json.dumps(payload),
1114
+ )
1115
+
1116
+ def get_bucket_subdomains(self, bucket_id: int = None):
1117
+ """Get bucket subdomains list."""
1118
+ url = f"{self.api_url}/storages/buckets/{bucket_id}/subdomains"
1119
+ return requests.get(url, headers=self.headers, timeout=self.timeout)
1120
+
1121
+ def add_bucket_subdomains(
1122
+ self,
1123
+ bucket_id: int = None,
1124
+ subdomains: list = None,
1125
+ ):
1126
+ """Add subdomains to bucket."""
1127
+ url = f"{self.api_url}/storages/buckets/{bucket_id}/subdomains"
1128
+ self.headers.update({"Content-Type": "application/json"})
1129
+ payload = {
1130
+ "subdomains": subdomains,
1131
+ }
1132
+ return requests.post(
1133
+ url,
1134
+ headers=self.headers,
1135
+ timeout=self.timeout,
1136
+ data=json.dumps(payload),
1137
+ )
1138
+
1139
+ def delete_bucket_subdomains(
1140
+ self,
1141
+ bucket_id: int = None,
1142
+ subdomains: list = None,
1143
+ ):
1144
+ """Delete bucket subdomains."""
1145
+ url = f"{self.api_url}/storages/buckets/{bucket_id}/subdomains"
1146
+ self.headers.update({"Content-Type": "application/json"})
1147
+ payload = {
1148
+ "subdomains": subdomains,
1149
+ }
1150
+ return requests.delete(
1151
+ url,
1152
+ headers=self.headers,
1153
+ timeout=self.timeout,
1154
+ data=json.dumps(payload),
1155
+ )
1156
+
1157
+ def gen_cert_for_bucket_subdomain(self, subdomain: str = None):
1158
+ """Generate TLS certificate for subdomain attached to bucket."""
1159
+ url = f"{self.api_url}/storages/certificates/generate"
1160
+ self.headers.update({"Content-Type": "application/json"})
1161
+ payload = {
1162
+ "subdomain": subdomain,
1163
+ }
1164
+ return requests.post(
1165
+ url,
1166
+ headers=self.headers,
1167
+ timeout=self.timeout,
1168
+ data=json.dumps(payload),
1169
+ )
@@ -37,10 +37,14 @@ def load_config(filepath: str = get_default_config_file()):
37
37
  try:
38
38
  with open(filepath, "r", encoding="utf-8") as file:
39
39
  return toml.load(file)
40
- except (OSError, FileNotFoundError) as error:
41
- sys.exit(f"Error: {filepath}: {error}: Try run 'twc config'")
40
+ except FileNotFoundError:
41
+ sys.exit(
42
+ f"Configuration file {filepath} not found. Try run 'twc config'"
43
+ )
44
+ except OSError as error:
45
+ sys.exit(f"Error: Cannot read configuration file {filepath}: {error}")
42
46
  except toml.TomlDecodeError as error:
43
- sys.exit(f"Error: {filepath}: {error}")
47
+ sys.exit(f"Error: Check your TOML syntax in file {filepath}: {error}")
44
48
 
45
49
 
46
50
  def set_value_from_config(ctx, param, value):
@@ -0,0 +1,165 @@
1
+ """CLI configuration."""
2
+
3
+ import os
4
+ import sys
5
+ import ctypes
6
+ import json
7
+
8
+ import yaml
9
+ import toml
10
+ import click
11
+ from click_default_group import DefaultGroup
12
+
13
+ from twc import fmt
14
+ from twc import utils
15
+ from twc.vars import DEFAULT_CONFIG
16
+ from . import (
17
+ options,
18
+ load_config,
19
+ get_default_config_file,
20
+ GLOBAL_OPTIONS,
21
+ )
22
+
23
+
24
+ def write_to_file(data: str, filepath: str):
25
+ try:
26
+ with open(filepath, "w", encoding="utf-8") as file:
27
+ toml.dump(data, 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"Done! Configuration is saved in {filepath}")
34
+ sys.exit(0)
35
+ except OSError as error:
36
+ sys.exit(f"Error: {error}")
37
+
38
+
39
+ def make_config(filepath: str = get_default_config_file()):
40
+ """Create new configuration file."""
41
+ if os.path.exists(filepath):
42
+ if click.confirm(
43
+ "You already have TWC CLI configured, continue?",
44
+ default=False,
45
+ ):
46
+ current_config = load_config()
47
+ profile = click.prompt("Enter profile name", default="default")
48
+ token = click.prompt(f"Enter API token for '{profile}'")
49
+ current_config[profile] = utils.merge_dicts(
50
+ current_config[profile],
51
+ {"token": token},
52
+ )
53
+ write_to_file(current_config, filepath)
54
+ else:
55
+ sys.exit("Aborted!")
56
+
57
+ click.echo("Create new configuration file. Enter your API token.")
58
+ while True:
59
+ token = input("Token: ")
60
+ if token:
61
+ DEFAULT_CONFIG.update({"default": {"token": token}})
62
+ break
63
+ click.echo("Please enter token. Press ^C to cancel.")
64
+ write_to_file(DEFAULT_CONFIG, filepath)
65
+
66
+
67
+ # ------------------------------------------------------------- #
68
+ # $ twc config #
69
+ # ------------------------------------------------------------- #
70
+
71
+
72
+ @click.group(
73
+ "config", cls=DefaultGroup, default="init", default_if_no_args=True
74
+ )
75
+ @options(GLOBAL_OPTIONS[:2])
76
+ def config():
77
+ """Manage CLI configuration."""
78
+
79
+
80
+ # ------------------------------------------------------------- #
81
+ # $ twc config init #
82
+ # ------------------------------------------------------------- #
83
+
84
+
85
+ @config.command("init", help="Create confiration file and profile.")
86
+ @options(GLOBAL_OPTIONS[:2])
87
+ def config_init():
88
+ make_config()
89
+
90
+
91
+ # ------------------------------------------------------------- #
92
+ # $ twc config file #
93
+ # ------------------------------------------------------------- #
94
+
95
+
96
+ @config.command("file", help="Display path to configuration file.")
97
+ @options(GLOBAL_OPTIONS[:4])
98
+ def config_file(config, verbose):
99
+ click.echo(click.format_filename(config.encode()))
100
+
101
+
102
+ # ------------------------------------------------------------- #
103
+ # $ twc config dump #
104
+ # ------------------------------------------------------------- #
105
+
106
+
107
+ @config.command("dump", help="Dump configuration.")
108
+ @options(GLOBAL_OPTIONS)
109
+ @click.option(
110
+ "--full", "full_dump", is_flag=True, help="Dump full configuration."
111
+ )
112
+ @click.option(
113
+ "--output",
114
+ "-o",
115
+ "output_format",
116
+ type=click.Choice(["toml", "yaml", "json"]),
117
+ default="toml",
118
+ show_default=True,
119
+ help="Output format.",
120
+ )
121
+ def config_dump(config, profile, verbose, full_dump, output_format):
122
+ if full_dump:
123
+ config_dict = load_config(config)
124
+ else:
125
+ config_dict = load_config(config)[profile]
126
+
127
+ if output_format == "toml":
128
+ fmt.print_colored(toml.dumps(config_dict), lang="toml")
129
+ elif output_format == "yaml":
130
+ fmt.print_colored(yaml.dump(config_dict), lang="yaml")
131
+ elif output_format == "json":
132
+ fmt.print_colored(json.dumps(config_dict), lang="json")
133
+
134
+
135
+ # ------------------------------------------------------------- #
136
+ # $ twc config set #
137
+ # ------------------------------------------------------------- #
138
+
139
+
140
+ @config.command("set", help="Set config parameter.")
141
+ @options(GLOBAL_OPTIONS)
142
+ @click.argument("params", nargs=-1, metavar="PARAM=VALUE")
143
+ def config_set(config, profile, verbose, params):
144
+ if not params:
145
+ raise click.UsageError("Nothing to do.")
146
+ config_dict = load_config(config)
147
+ params_dict = {}
148
+ for param in params:
149
+ key, value = param.split("=")
150
+ if value.isdigit():
151
+ value = int(value)
152
+ params_dict[key] = value
153
+ config_dict[profile] = utils.merge_dicts(config_dict[profile], params_dict)
154
+ write_to_file(config_dict, config)
155
+
156
+
157
+ # ------------------------------------------------------------- #
158
+ # $ twc config edit #
159
+ # ------------------------------------------------------------- #
160
+
161
+
162
+ @config.command("edit", help="Open config file in default editor.")
163
+ @options(GLOBAL_OPTIONS[:4])
164
+ def config_edit(config, verbose):
165
+ click.launch(config)