cdmclient 1.2.4__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.
@@ -0,0 +1,39 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ .ruff_cache/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .coverage
12
+ .coverage.*
13
+ htmlcov/
14
+ celerybeat-schedule
15
+ celerybeat.pid
16
+
17
+ # Environment
18
+ .env
19
+ .env.sh
20
+
21
+ # Node / frontend
22
+ node_modules/
23
+ webapp/frontend/build/
24
+
25
+ # Webapp built assets (generated by frontend build)
26
+ webapp/assets/*
27
+ webapp/templates/*
28
+ !webapp/templates/email/
29
+ !webapp/templates/email/**
30
+
31
+ # Browser extension
32
+ browser-extension/cdm.zip
33
+
34
+ # Editor / OS
35
+ .idea/
36
+ .vscode/
37
+ .claude/
38
+ .DS_Store
39
+ *.log
@@ -0,0 +1 @@
1
+ 3.9
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Aron Radics
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,14 @@
1
+ format:
2
+ uv run ruff format cdm_client/
3
+ uv run ruff check --fix cdm_client/
4
+
5
+ check-format-ci:
6
+ uv run ruff format --check cdm_client/
7
+ uv run ruff check cdm_client/
8
+
9
+ lint:
10
+ uv run ruff check cdm_client/
11
+ uv run ty check cdm_client/
12
+
13
+ bump:
14
+ uv version --bump $(filter-out $@,$(MAKECMDGOALS))
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: cdmclient
3
+ Version: 1.2.4
4
+ Summary: CDM - Centralized Download Manager Client
5
+ Project-URL: Homepage, https://github.com/radaron/CDMServer/tree/master/client
6
+ Project-URL: Repository, https://github.com/radaron/CDMServer
7
+ Author-email: Aron Radics <radics.aron.jozsef@gmail.com>
8
+ License: MIT License
9
+
10
+ Copyright (c) 2024 Aron Radics
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Classifier: Operating System :: OS Independent
31
+ Classifier: Programming Language :: Python :: 3
32
+ Requires-Python: >=3.9
33
+ Requires-Dist: cryptography
34
+ Requires-Dist: qbittorrent-api>=2025.7.0
35
+ Requires-Dist: requests
36
+ Requires-Dist: sqlalchemy>=2.0.0
37
+ Requires-Dist: transmission-rpc
38
+ Description-Content-Type: text/markdown
39
+
40
+ # CDMClient
41
+ **CDM - Centralized Download Manager Client**
42
+ CDMClient is the client application for the [CDM Server](../README.md).
43
+
44
+ This client communicates with the CDM Server to periodically send the download status from Transmission/QBittorrent and check for new torrent files to download. If new torrents are available, they are downloaded and added to Transmission/QBittorrent client.
45
+
46
+ ## Installation
47
+
48
+ ### Install or Upgrade the Package
49
+ Run the following command to install or upgrade CDMClient:
50
+ ```shell
51
+ python3 -m pip install --upgrade cdmclient --user
52
+ ```
53
+
54
+ ### Create a Systemd Service
55
+ To set up CDMClient as a systemd service, execute the following commands:
56
+
57
+ 1. Create the service file:
58
+ ```shell
59
+ echo "[Unit]
60
+ Description=cdm-client service
61
+ After=multi-user.target
62
+ Conflicts=getty@tty1.service
63
+
64
+ [Service]
65
+ User=${USER}
66
+ Type=simple
67
+ Environment=LC_ALL=C.UTF-8
68
+ Environment=LANG=C.UTF-8
69
+ ExecStart=${HOME}/.local/bin/cdm-client
70
+ Restart=on-failure
71
+ RestartSec=3
72
+
73
+ [Install]
74
+ WantedBy=multi-user.target" | sudo tee /etc/systemd/system/cdm-client.service
75
+ ```
76
+
77
+ 2. Reload systemd and enable the service:
78
+ ```shell
79
+ sudo systemctl daemon-reload
80
+ sudo systemctl enable cdm-client.service
81
+ sudo systemctl start cdm-client.service
82
+ ```
83
+
84
+ ### Add automatic update (optional)
85
+ To keep CDMClient updated automatically, you can add a cron job. Run:
86
+ ```shell
87
+ crontab -e
88
+ ```
89
+ Then add the following line to the crontab file:
90
+ ```shell
91
+ 0 3 * * * /usr/bin/python3 -m pip install --upgrade cdmclient --user && systemctl restart cdm-client.service
92
+ ```
93
+
94
+ ## Configuration
95
+ The configuration file is located at:
96
+ ```~/.config/cdm_client/config.ini```
97
+
98
+ This file is automatically generated when the service starts. Below is an example configuration:
99
+
100
+ ```ini
101
+ [connection]
102
+ server_host = "https://cdmserver.com"
103
+ api_key = "1409d6ed79280aadcbe20f8f21981e89"
104
+ client_host =
105
+ client_port =
106
+ client_username =
107
+ client_password =
108
+ client_type= transmission # possible values: transmission or qbitorrent
109
+ ```
110
+
111
+ ### Configuration Details:
112
+ - **`server_host`**: The full URL of the CDM Server (e.g., `https://cdmserver.com`).
113
+ - **`api_key`**: The API key generated when adding a new device on the CDM Server's devices page. Refer to the [Devices Page Documentation](../doc/USAGE.md#devices-page).
114
+ - **`client_username`** and **`client_password`**: Required authentication for the selected torrent client.
115
+ - **`client_type`**: Specify either `transmission` or `qbitorrent`, depending on the torrent client you are using.
116
+ - **`client_host`** and **`client_port`**: Optional. If not specified, defaults to `localhost` and the default port for the selected client type.
117
+
118
+ ## Viewing Logs
119
+ To monitor the service logs, use the following command:
120
+ ```shell
121
+ journalctl -fu cdm-client
122
+ ```
@@ -0,0 +1,83 @@
1
+ # CDMClient
2
+ **CDM - Centralized Download Manager Client**
3
+ CDMClient is the client application for the [CDM Server](../README.md).
4
+
5
+ This client communicates with the CDM Server to periodically send the download status from Transmission/QBittorrent and check for new torrent files to download. If new torrents are available, they are downloaded and added to Transmission/QBittorrent client.
6
+
7
+ ## Installation
8
+
9
+ ### Install or Upgrade the Package
10
+ Run the following command to install or upgrade CDMClient:
11
+ ```shell
12
+ python3 -m pip install --upgrade cdmclient --user
13
+ ```
14
+
15
+ ### Create a Systemd Service
16
+ To set up CDMClient as a systemd service, execute the following commands:
17
+
18
+ 1. Create the service file:
19
+ ```shell
20
+ echo "[Unit]
21
+ Description=cdm-client service
22
+ After=multi-user.target
23
+ Conflicts=getty@tty1.service
24
+
25
+ [Service]
26
+ User=${USER}
27
+ Type=simple
28
+ Environment=LC_ALL=C.UTF-8
29
+ Environment=LANG=C.UTF-8
30
+ ExecStart=${HOME}/.local/bin/cdm-client
31
+ Restart=on-failure
32
+ RestartSec=3
33
+
34
+ [Install]
35
+ WantedBy=multi-user.target" | sudo tee /etc/systemd/system/cdm-client.service
36
+ ```
37
+
38
+ 2. Reload systemd and enable the service:
39
+ ```shell
40
+ sudo systemctl daemon-reload
41
+ sudo systemctl enable cdm-client.service
42
+ sudo systemctl start cdm-client.service
43
+ ```
44
+
45
+ ### Add automatic update (optional)
46
+ To keep CDMClient updated automatically, you can add a cron job. Run:
47
+ ```shell
48
+ crontab -e
49
+ ```
50
+ Then add the following line to the crontab file:
51
+ ```shell
52
+ 0 3 * * * /usr/bin/python3 -m pip install --upgrade cdmclient --user && systemctl restart cdm-client.service
53
+ ```
54
+
55
+ ## Configuration
56
+ The configuration file is located at:
57
+ ```~/.config/cdm_client/config.ini```
58
+
59
+ This file is automatically generated when the service starts. Below is an example configuration:
60
+
61
+ ```ini
62
+ [connection]
63
+ server_host = "https://cdmserver.com"
64
+ api_key = "1409d6ed79280aadcbe20f8f21981e89"
65
+ client_host =
66
+ client_port =
67
+ client_username =
68
+ client_password =
69
+ client_type= transmission # possible values: transmission or qbitorrent
70
+ ```
71
+
72
+ ### Configuration Details:
73
+ - **`server_host`**: The full URL of the CDM Server (e.g., `https://cdmserver.com`).
74
+ - **`api_key`**: The API key generated when adding a new device on the CDM Server's devices page. Refer to the [Devices Page Documentation](../doc/USAGE.md#devices-page).
75
+ - **`client_username`** and **`client_password`**: Required authentication for the selected torrent client.
76
+ - **`client_type`**: Specify either `transmission` or `qbitorrent`, depending on the torrent client you are using.
77
+ - **`client_host`** and **`client_port`**: Optional. If not specified, defaults to `localhost` and the default port for the selected client type.
78
+
79
+ ## Viewing Logs
80
+ To monitor the service logs, use the following command:
81
+ ```shell
82
+ journalctl -fu cdm-client
83
+ ```
File without changes
@@ -0,0 +1,246 @@
1
+ import logging
2
+ import os
3
+ import shutil
4
+ from enum import Enum
5
+ from logging.handlers import SysLogHandler
6
+ from time import sleep
7
+ from typing import Optional
8
+
9
+ import requests
10
+
11
+ from cdm_client.config import Config
12
+ from cdm_client.database_adapter import DatabaseAdapter
13
+ from cdm_client.torrent_client_factory import (
14
+ TorrentClientType,
15
+ create_torrent_client_adapter,
16
+ )
17
+
18
+
19
+ class InstructionAction(Enum):
20
+ STOP = "stop"
21
+ START = "start"
22
+ DELETE = "delete"
23
+ CLEAN = "clean"
24
+
25
+
26
+ class CDMClient:
27
+ def __init__(self) -> None:
28
+ self._logger = self._init_logger()
29
+ self._config = Config()
30
+ self._torrent_client_adapter = create_torrent_client_adapter(
31
+ TorrentClientType.get_enum_from_value(self._config["client_type"]),
32
+ username=self._config["client_username"] or None,
33
+ password=self._config["client_password"] or None,
34
+ host=self._config["client_host"] or None,
35
+ port=int(self._config["client_port"])
36
+ if self._config["client_port"]
37
+ else None,
38
+ )
39
+ self._database_adapter = DatabaseAdapter()
40
+
41
+ def _init_logger(self) -> logging.Logger:
42
+ syslog = SysLogHandler(address="/dev/log")
43
+ syslog.setFormatter(
44
+ logging.Formatter("cdm-client %(name)s: %(levelname)s %(message)s")
45
+ )
46
+ logger = logging.getLogger("cdm-client")
47
+ logger.addHandler(syslog)
48
+ logger.setLevel(logging.INFO)
49
+ return logger
50
+
51
+ def _update_status(self, status_data: list[dict]) -> None:
52
+ resp = requests.post(
53
+ f"{self._config['server_host']}/api/client/status/",
54
+ json={"data": status_data},
55
+ headers={"x-api-key": self._config["api_key"]},
56
+ timeout=5,
57
+ )
58
+ resp.raise_for_status()
59
+
60
+ def _download_files(self, files: dict[int, str]) -> None:
61
+ for tracker_id, path in files.items():
62
+ resp = requests.get(
63
+ f"{self._config['server_host']}/api/client/download/{tracker_id}/",
64
+ headers={"x-api-key": self._config["api_key"]},
65
+ stream=True,
66
+ timeout=5,
67
+ )
68
+ resp.raise_for_status()
69
+ new_torrent = self._torrent_client_adapter.add_torrent(
70
+ resp.content, download_dir=path
71
+ )
72
+ if new_torrent is None:
73
+ self._logger.error(
74
+ "Failed to add torrent for tracker_id=%s to %s", tracker_id, path
75
+ )
76
+ continue
77
+ with self._database_adapter as db_adapter:
78
+ status = db_adapter.create_or_update_download_torrent_mapping(
79
+ tracker_id=tracker_id,
80
+ torrent_hash=self._torrent_client_adapter.get_status_by_id(
81
+ new_torrent.id
82
+ )["hash"],
83
+ )
84
+ if not status:
85
+ self._logger.error(
86
+ "Failed to save download-torrent mapping: %s", status
87
+ )
88
+ self._logger.info("Downloading torrent: %s to %s", tracker_id, path)
89
+
90
+ def _execute_instructions(self, instructions: list[dict]) -> None:
91
+ for instruction in instructions:
92
+ self._logger.info("Received instruction: %s", instruction)
93
+ for action, params in instruction.items():
94
+ if action == InstructionAction.STOP.value:
95
+ self._torrent_client_adapter.pause_torrent(params["torrent_id"])
96
+ self._logger.info("Stopped torrent: %s", params["torrent_id"])
97
+ elif action == InstructionAction.START.value:
98
+ self._torrent_client_adapter.resume_torrent(params["torrent_id"])
99
+ self._logger.info("Started torrent: %s", params["torrent_id"])
100
+ elif action == InstructionAction.DELETE.value:
101
+ self.delete_download(params["torrent_id"])
102
+ elif action == InstructionAction.CLEAN.value:
103
+ self.clean_download_paths(params["paths"])
104
+ else:
105
+ self._logger.warning("Unknown instruction action: %s", action)
106
+
107
+ def _normalize_path(self, path: str) -> str:
108
+ return os.path.normpath(os.path.realpath(path))
109
+
110
+ def _is_protected_path(self, path: str, protected_paths: set[str]) -> bool:
111
+ normalized_path = self._normalize_path(path)
112
+ for protected_path in protected_paths:
113
+ if protected_path == normalized_path or protected_path.startswith(
114
+ f"{normalized_path}{os.sep}"
115
+ ):
116
+ return True
117
+ return False
118
+
119
+ def _delete_path(self, path: str) -> None:
120
+ if os.path.islink(path) or os.path.isfile(path):
121
+ os.remove(path)
122
+ else:
123
+ shutil.rmtree(path)
124
+
125
+ def clean_download_paths(self, paths: list[str]) -> None:
126
+ if not paths:
127
+ self._logger.warning("No clean paths provided")
128
+ return
129
+
130
+ status_entries = self._torrent_client_adapter.get_status()
131
+ protected_paths: set[str] = set()
132
+ for status_entry in status_entries:
133
+ download_dir = status_entry.get("downloadDir")
134
+ name = status_entry.get("name")
135
+ if not isinstance(download_dir, str) or not isinstance(name, str):
136
+ continue
137
+ protected_paths.add(self._normalize_path(os.path.join(download_dir, name)))
138
+
139
+ for path in paths:
140
+ clean_path = self._normalize_path(path)
141
+ if not os.path.isdir(clean_path):
142
+ self._logger.warning("Clean path does not exist: %s", clean_path)
143
+ continue
144
+
145
+ self._logger.info("Cleaning path: %s", clean_path)
146
+ for entry in os.scandir(clean_path):
147
+ entry_path = self._normalize_path(entry.path)
148
+ if self._is_protected_path(entry_path, protected_paths):
149
+ continue
150
+ try:
151
+ self._delete_path(entry_path)
152
+ self._logger.info("Deleted stale path: %s", entry_path)
153
+ except FileNotFoundError:
154
+ self._logger.warning(
155
+ "Path disappeared during clean: %s", entry_path
156
+ )
157
+ except OSError:
158
+ self._logger.exception(
159
+ "Failed to delete stale path: %s", entry_path
160
+ )
161
+
162
+ def delete_download(self, torrent_id: int) -> None:
163
+ try:
164
+ status_data = self._get_download_status(
165
+ torrent_id=torrent_id, for_deletion=True
166
+ )
167
+ self._torrent_client_adapter.remove_torrent(torrent_id)
168
+ self._update_status(status_data)
169
+ self._force_delete_if_exists(
170
+ os.path.join(status_data[0]["downloadDir"], status_data[0]["name"])
171
+ )
172
+ finally:
173
+ with self._database_adapter as db_adapter:
174
+ deleted_mapping = db_adapter.delete_mapping(
175
+ torrent_hash=status_data[0]["hash"]
176
+ )
177
+ self._update_status(status_data)
178
+
179
+ self._logger.info(
180
+ "Removed torrent and data: %s, deleted_mapping: %s",
181
+ torrent_id,
182
+ deleted_mapping,
183
+ )
184
+
185
+ def _force_delete_if_exists(self, file_path: str) -> None:
186
+ sleep(1)
187
+ if os.path.exists(file_path):
188
+ self._logger.info("Force deleting file: %s", file_path)
189
+ try:
190
+ self._delete_path(file_path)
191
+ except FileNotFoundError:
192
+ self._logger.warning("File not found during deletion")
193
+
194
+ def _get_order(self) -> None:
195
+ resp = requests.get(
196
+ f"{self._config['server_host']}/api/client/",
197
+ headers={"x-api-key": self._config["api_key"]},
198
+ timeout=5,
199
+ )
200
+ resp.raise_for_status()
201
+
202
+ files = resp.json()["data"]["files"]
203
+ if files:
204
+ self._download_files(files)
205
+ instructions = resp.json()["data"]["instructions"]
206
+ if instructions:
207
+ self._execute_instructions(instructions)
208
+ self._update_status(self._get_download_status())
209
+
210
+ def _get_download_status(
211
+ self, torrent_id: Optional[int] = None, for_deletion: bool = False
212
+ ) -> list[dict]:
213
+ status = []
214
+ if torrent_id:
215
+ status.append(self._torrent_client_adapter.get_status_by_id(torrent_id))
216
+ else:
217
+ status = self._torrent_client_adapter.get_status()
218
+ with self._database_adapter as db_adapter:
219
+ for status_entry in status:
220
+ tracker_id = db_adapter.get_tracker_id_by_torrent_hash(
221
+ status_entry["hash"]
222
+ )
223
+ if tracker_id:
224
+ status_entry["tracker_id"] = tracker_id
225
+ if for_deletion:
226
+ status_entry["is_deleted"] = True
227
+ return status
228
+
229
+ def run(self) -> None:
230
+ self._logger.info("Starting cdm-client...")
231
+ while True:
232
+ try:
233
+ self._update_status(self._get_download_status())
234
+ self._get_order()
235
+ except Exception:
236
+ self._logger.exception("An error occurred.")
237
+ sleep(5)
238
+
239
+
240
+ def main() -> None:
241
+ cdm_client = CDMClient()
242
+ cdm_client.run()
243
+
244
+
245
+ if __name__ == "__main__":
246
+ main()
@@ -0,0 +1,90 @@
1
+ import configparser
2
+ import os
3
+ from typing import Union
4
+
5
+ from cryptography.fernet import Fernet
6
+
7
+
8
+ class Config:
9
+ CONFIG_FOLDER_PATH = os.path.join(os.path.expanduser("~"), ".config", "cdm_client")
10
+ KEY_PATH = os.path.join(CONFIG_FOLDER_PATH, "key.key")
11
+ CONFIG_PATH = os.path.join(CONFIG_FOLDER_PATH, "config.ini")
12
+ DEFAULT_CONFIG = {
13
+ "connection": {
14
+ "server_host": "",
15
+ "api_key": "",
16
+ "client_host": "",
17
+ "client_port": "",
18
+ "client_username": "",
19
+ "client_password": "",
20
+ "client_type": "",
21
+ }
22
+ }
23
+ ENCRYPTED_CONFIG = ["client_password"]
24
+
25
+ def __init__(self) -> None:
26
+ self._key: Union[bytes, str] = ""
27
+ self._config = self._load_config()
28
+ self._check_key()
29
+
30
+ def _check_key(self) -> None:
31
+ if not self._key_exists:
32
+ self._create_key()
33
+ else:
34
+ self._read_key()
35
+
36
+ def _create_key(self) -> None:
37
+ self._key = Fernet.generate_key()
38
+ with open(self.KEY_PATH, "w", encoding="utf-8") as f:
39
+ f.write(self._key.decode())
40
+
41
+ def _read_key(self) -> None:
42
+ with open(self.KEY_PATH, "r", encoding="utf-8") as f:
43
+ self._key = f.read().encode()
44
+
45
+ def _load_config(self) -> configparser.ConfigParser:
46
+ config = configparser.ConfigParser()
47
+ if config.read(self.CONFIG_PATH):
48
+ return config
49
+ return self._create_config()
50
+
51
+ def _create_config(self) -> configparser.ConfigParser:
52
+ if not os.path.exists(self.CONFIG_FOLDER_PATH):
53
+ os.makedirs(self.CONFIG_FOLDER_PATH)
54
+ config = configparser.ConfigParser()
55
+ for section, configs in self.DEFAULT_CONFIG.items():
56
+ config[section] = {}
57
+ for config_key, config_value in configs.items():
58
+ config[section][config_key] = config_value
59
+ with open(self.CONFIG_PATH, "w", encoding="utf-8") as f:
60
+ config.write(f)
61
+ return config
62
+
63
+ def _decrypt(self, value: str) -> str:
64
+ f = Fernet(self._key)
65
+ return f.decrypt(value.encode()).decode()
66
+
67
+ def _encrypt(self, value: str) -> str:
68
+ f = Fernet(self._key)
69
+ return f.encrypt(value.encode()).decode()
70
+
71
+ def _write_creds(self) -> None:
72
+ with open(self.CONFIG_PATH, "w", encoding="utf-8") as f:
73
+ self._config.write(f)
74
+
75
+ @property
76
+ def _key_exists(self) -> bool:
77
+ return os.path.exists(self.KEY_PATH)
78
+
79
+ def __getitem__(self, name: str) -> str:
80
+ if name in self.ENCRYPTED_CONFIG:
81
+ try:
82
+ return self._decrypt(self._config["connection"][name])
83
+ except Exception:
84
+ raw_value = self._config["connection"][name]
85
+ self._config["connection"][name] = self._encrypt(
86
+ self._config["connection"][name]
87
+ )
88
+ self._write_creds()
89
+ return raw_value
90
+ return self._config["connection"][name]