cave-cli 3.5.0__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.
- cave_cli/__init__.py +3 -0
- cave_cli/cli.py +469 -0
- cave_cli/commands/__init__.py +0 -0
- cave_cli/commands/create.py +168 -0
- cave_cli/commands/kill.py +23 -0
- cave_cli/commands/list_cmd.py +37 -0
- cave_cli/commands/list_versions.py +93 -0
- cave_cli/commands/prettify.py +27 -0
- cave_cli/commands/purge.py +70 -0
- cave_cli/commands/reset.py +43 -0
- cave_cli/commands/run.py +189 -0
- cave_cli/commands/sync_cmd.py +79 -0
- cave_cli/commands/test.py +38 -0
- cave_cli/commands/uninstall.py +39 -0
- cave_cli/commands/update.py +38 -0
- cave_cli/commands/upgrade.py +70 -0
- cave_cli/commands/version.py +64 -0
- cave_cli/utils/__init__.py +0 -0
- cave_cli/utils/cache.py +235 -0
- cave_cli/utils/constants.py +43 -0
- cave_cli/utils/docker.py +530 -0
- cave_cli/utils/env.py +267 -0
- cave_cli/utils/git.py +240 -0
- cave_cli/utils/logger.py +77 -0
- cave_cli/utils/net.py +85 -0
- cave_cli/utils/subprocess.py +129 -0
- cave_cli/utils/sync.py +89 -0
- cave_cli/utils/validate.py +218 -0
- cave_cli-3.5.0.dist-info/METADATA +149 -0
- cave_cli-3.5.0.dist-info/RECORD +35 -0
- cave_cli-3.5.0.dist-info/WHEEL +5 -0
- cave_cli-3.5.0.dist-info/entry_points.txt +2 -0
- cave_cli-3.5.0.dist-info/licenses/LICENSE +201 -0
- cave_cli-3.5.0.dist-info/licenses/NOTICE.md +7 -0
- cave_cli-3.5.0.dist-info/top_level.txt +1 -0
cave_cli/utils/env.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import getpass
|
|
2
|
+
import re
|
|
3
|
+
import secrets
|
|
4
|
+
import string
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from cave_cli.utils.cache import prompt_cached_entry, load_entries, save_entry
|
|
8
|
+
from cave_cli.utils.constants import (
|
|
9
|
+
CURRENT_ENV_VARIABLES,
|
|
10
|
+
RETIRED_ENV_VARIABLES,
|
|
11
|
+
)
|
|
12
|
+
from cave_cli.utils.logger import logger
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_env(path: str) -> dict[str, str]:
|
|
16
|
+
"""
|
|
17
|
+
Usage:
|
|
18
|
+
|
|
19
|
+
- Parses a .env file into a dictionary of key-value pairs
|
|
20
|
+
|
|
21
|
+
Requires:
|
|
22
|
+
|
|
23
|
+
- ``path``:
|
|
24
|
+
- Type: str
|
|
25
|
+
- What: The path to the .env file
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
|
|
29
|
+
- ``env_vars``:
|
|
30
|
+
- Type: dict[str, str]
|
|
31
|
+
- What: A dictionary mapping variable names to their values
|
|
32
|
+
|
|
33
|
+
Notes:
|
|
34
|
+
|
|
35
|
+
- Handles ``KEY=VALUE``, ``KEY='VALUE'``, and ``KEY="VALUE"`` formats
|
|
36
|
+
- Ignores blank lines and lines starting with ``#``
|
|
37
|
+
"""
|
|
38
|
+
env_vars: dict[str, str] = {}
|
|
39
|
+
p = Path(path)
|
|
40
|
+
if not p.is_file():
|
|
41
|
+
return env_vars
|
|
42
|
+
for line in p.read_text().splitlines():
|
|
43
|
+
line = line.strip()
|
|
44
|
+
if not line or line.startswith("#"):
|
|
45
|
+
continue
|
|
46
|
+
if "=" not in line:
|
|
47
|
+
continue
|
|
48
|
+
key, _, value = line.partition("=")
|
|
49
|
+
key = key.strip()
|
|
50
|
+
value = value.strip()
|
|
51
|
+
if (value.startswith("'") and value.endswith("'")) or (
|
|
52
|
+
value.startswith('"') and value.endswith('"')
|
|
53
|
+
):
|
|
54
|
+
value = value[1:-1]
|
|
55
|
+
env_vars[key] = value
|
|
56
|
+
return env_vars
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def set_env_value(path: str, key: str, value: str) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Usage:
|
|
62
|
+
|
|
63
|
+
- Sets or replaces a variable's value in a .env file
|
|
64
|
+
|
|
65
|
+
Requires:
|
|
66
|
+
|
|
67
|
+
- ``path``:
|
|
68
|
+
- Type: str
|
|
69
|
+
- What: The path to the .env file
|
|
70
|
+
|
|
71
|
+
- ``key``:
|
|
72
|
+
- Type: str
|
|
73
|
+
- What: The variable name to set
|
|
74
|
+
|
|
75
|
+
- ``value``:
|
|
76
|
+
- Type: str
|
|
77
|
+
- What: The value to assign
|
|
78
|
+
"""
|
|
79
|
+
p = Path(path)
|
|
80
|
+
lines = p.read_text().replace("\r\n", "\n").replace("\r", "\n").splitlines()
|
|
81
|
+
pattern = re.compile(rf"^{re.escape(key)}\s*=")
|
|
82
|
+
replaced = False
|
|
83
|
+
for i, line in enumerate(lines):
|
|
84
|
+
if pattern.match(line):
|
|
85
|
+
lines[i] = f"{key}='{value}'"
|
|
86
|
+
replaced = True
|
|
87
|
+
break
|
|
88
|
+
if not replaced:
|
|
89
|
+
lines.append(f"{key}='{value}'")
|
|
90
|
+
p.write_text("\n".join(lines) + "\n", newline="\n")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def validate_env(path: str) -> list[str]:
|
|
94
|
+
"""
|
|
95
|
+
Usage:
|
|
96
|
+
|
|
97
|
+
- Validates a .env file has all required variables and no retired ones
|
|
98
|
+
|
|
99
|
+
Requires:
|
|
100
|
+
|
|
101
|
+
- ``path``:
|
|
102
|
+
- Type: str
|
|
103
|
+
- What: The path to the .env file
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
|
|
107
|
+
- ``errors``:
|
|
108
|
+
- Type: list[str]
|
|
109
|
+
- What: A list of error messages. Empty if valid.
|
|
110
|
+
"""
|
|
111
|
+
errors: list[str] = []
|
|
112
|
+
env_vars = parse_env(path)
|
|
113
|
+
for var in CURRENT_ENV_VARIABLES:
|
|
114
|
+
if var not in env_vars:
|
|
115
|
+
errors.append(
|
|
116
|
+
f"The env variable '{var}' is missing "
|
|
117
|
+
"from the '.env' file."
|
|
118
|
+
)
|
|
119
|
+
for var in RETIRED_ENV_VARIABLES:
|
|
120
|
+
if var in env_vars:
|
|
121
|
+
errors.append(
|
|
122
|
+
f"The env variable '{var}' is retired and "
|
|
123
|
+
"should be removed from the '.env' file."
|
|
124
|
+
)
|
|
125
|
+
return errors
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def upgrade_env(env_path: str, template_path: str) -> None:
|
|
129
|
+
"""
|
|
130
|
+
Usage:
|
|
131
|
+
|
|
132
|
+
- Upgrades the STATIC_APP_URL_PATH in .env from a template's example.env
|
|
133
|
+
|
|
134
|
+
Requires:
|
|
135
|
+
|
|
136
|
+
- ``env_path``:
|
|
137
|
+
- Type: str
|
|
138
|
+
- What: The path to the app's .env file
|
|
139
|
+
|
|
140
|
+
- ``template_path``:
|
|
141
|
+
- Type: str
|
|
142
|
+
- What: The path to the cloned template directory
|
|
143
|
+
"""
|
|
144
|
+
logger.info("Upgrading .env...")
|
|
145
|
+
example_env = parse_env(str(Path(template_path) / "example.env"))
|
|
146
|
+
new_url_path = example_env.get("STATIC_APP_URL_PATH", "")
|
|
147
|
+
if new_url_path:
|
|
148
|
+
set_env_value(env_path, "STATIC_APP_URL_PATH", new_url_path)
|
|
149
|
+
logger.info("Done")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def generate_password(length: int = 16) -> str:
|
|
153
|
+
alphabet = string.ascii_letters + string.digits
|
|
154
|
+
return "".join(secrets.choice(alphabet) for _ in range(length))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def prompt_password(prompt: str) -> str:
|
|
158
|
+
while True:
|
|
159
|
+
password = getpass.getpass(prompt)
|
|
160
|
+
if not password:
|
|
161
|
+
password = generate_password()
|
|
162
|
+
return password
|
|
163
|
+
confirm = getpass.getpass("Retype password to confirm: ")
|
|
164
|
+
if password == confirm:
|
|
165
|
+
return password
|
|
166
|
+
print("Passwords didn't match. Please try again")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def create_env_interactive(
|
|
170
|
+
app_name: str,
|
|
171
|
+
env_path: str,
|
|
172
|
+
template_path: str,
|
|
173
|
+
docker_secret_key: str | None = None,
|
|
174
|
+
) -> None:
|
|
175
|
+
"""
|
|
176
|
+
Usage:
|
|
177
|
+
|
|
178
|
+
- Interactively creates a .env file for a new CAVE app
|
|
179
|
+
|
|
180
|
+
Requires:
|
|
181
|
+
|
|
182
|
+
- ``app_name``:
|
|
183
|
+
- Type: str
|
|
184
|
+
- What: The name of the app being created
|
|
185
|
+
|
|
186
|
+
- ``env_path``:
|
|
187
|
+
- Type: str
|
|
188
|
+
- What: The path where the .env file will be written
|
|
189
|
+
|
|
190
|
+
- ``template_path``:
|
|
191
|
+
- Type: str
|
|
192
|
+
- What: The path to the example.env template
|
|
193
|
+
|
|
194
|
+
Optional:
|
|
195
|
+
|
|
196
|
+
- ``docker_secret_key``:
|
|
197
|
+
- Type: str | None
|
|
198
|
+
- What: A Django secret key generated via Docker
|
|
199
|
+
- Default: None (generates locally with secrets module)
|
|
200
|
+
"""
|
|
201
|
+
p = Path(env_path)
|
|
202
|
+
template = Path(template_path)
|
|
203
|
+
if template.is_file():
|
|
204
|
+
content = template.read_text().replace("\r\n", "\n").replace("\r", "\n")
|
|
205
|
+
p.write_text(content, newline="\n")
|
|
206
|
+
elif p.is_file():
|
|
207
|
+
pass
|
|
208
|
+
else:
|
|
209
|
+
p.touch()
|
|
210
|
+
|
|
211
|
+
secret_key = docker_secret_key or secrets.token_urlsafe(50)
|
|
212
|
+
set_env_value(env_path, "SECRET_KEY", secret_key)
|
|
213
|
+
|
|
214
|
+
logger.header("Set up your new app environment (.env) variables:")
|
|
215
|
+
|
|
216
|
+
logger.info(
|
|
217
|
+
"If you want to use a globe view or mapbox maps, "
|
|
218
|
+
"you will need a valid Mapbox Token."
|
|
219
|
+
)
|
|
220
|
+
logger.info(
|
|
221
|
+
"This is not required, but will allow you to use "
|
|
222
|
+
"the full functionality of the app."
|
|
223
|
+
)
|
|
224
|
+
logger.info(
|
|
225
|
+
"Mapbox tokens can be created by making an account "
|
|
226
|
+
"on 'https://mapbox.com'"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
use_mapbox = input("Would you like to use Mapbox? [y/N] ")
|
|
231
|
+
except (EOFError, KeyboardInterrupt):
|
|
232
|
+
use_mapbox = "n"
|
|
233
|
+
|
|
234
|
+
if use_mapbox.strip().lower() in ("y", "yes"):
|
|
235
|
+
token = prompt_cached_entry(
|
|
236
|
+
name="mapbox_tokens",
|
|
237
|
+
prompt_new="Enter your Mapbox Public Token: ",
|
|
238
|
+
prompt_label="Label for this token (e.g. work, personal): ",
|
|
239
|
+
mask=True,
|
|
240
|
+
)
|
|
241
|
+
if token:
|
|
242
|
+
set_env_value(env_path, "MAPBOX_TOKEN", token)
|
|
243
|
+
else:
|
|
244
|
+
logger.info("Mapbox skipped")
|
|
245
|
+
|
|
246
|
+
default_email = f"{app_name}@example.com"
|
|
247
|
+
print()
|
|
248
|
+
email = prompt_cached_entry(
|
|
249
|
+
name="admin_emails",
|
|
250
|
+
prompt_new="Enter an admin email",
|
|
251
|
+
prompt_label="Label for this email (e.g. work, personal): ",
|
|
252
|
+
default=default_email,
|
|
253
|
+
)
|
|
254
|
+
if not email:
|
|
255
|
+
email = default_email
|
|
256
|
+
set_env_value(env_path, "DJANGO_ADMIN_EMAIL", email)
|
|
257
|
+
|
|
258
|
+
print()
|
|
259
|
+
admin_password = prompt_password(
|
|
260
|
+
"Please input an admin password. "
|
|
261
|
+
"Leave blank to randomly generate one: "
|
|
262
|
+
)
|
|
263
|
+
set_env_value(env_path, "DJANGO_ADMIN_PASSWORD", admin_password)
|
|
264
|
+
|
|
265
|
+
db_password = generate_password()
|
|
266
|
+
set_env_value(env_path, "DATABASE_PASSWORD", db_password)
|
|
267
|
+
print()
|
cave_cli/utils/git.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
from cave_cli.utils.subprocess import run, run_and_log
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def clone(
|
|
7
|
+
url: str,
|
|
8
|
+
dest: str,
|
|
9
|
+
branch: str | None = None,
|
|
10
|
+
single_branch: bool = True,
|
|
11
|
+
) -> bool:
|
|
12
|
+
"""
|
|
13
|
+
Usage:
|
|
14
|
+
|
|
15
|
+
- Clones a git repository
|
|
16
|
+
|
|
17
|
+
Requires:
|
|
18
|
+
|
|
19
|
+
- ``url``:
|
|
20
|
+
- Type: str
|
|
21
|
+
- What: The repository URL to clone
|
|
22
|
+
|
|
23
|
+
- ``dest``:
|
|
24
|
+
- Type: str
|
|
25
|
+
- What: The destination directory
|
|
26
|
+
|
|
27
|
+
Optional:
|
|
28
|
+
|
|
29
|
+
- ``branch``:
|
|
30
|
+
- Type: str | None
|
|
31
|
+
- What: A specific branch or tag to clone
|
|
32
|
+
- Default: None (clones the default branch)
|
|
33
|
+
|
|
34
|
+
- ``single_branch``:
|
|
35
|
+
- Type: bool
|
|
36
|
+
- What: Whether to clone only the specified branch
|
|
37
|
+
- Default: True
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
|
|
41
|
+
- ``success``:
|
|
42
|
+
- Type: bool
|
|
43
|
+
- What: True if the clone succeeded, False otherwise
|
|
44
|
+
"""
|
|
45
|
+
cmd = ["git", "clone"]
|
|
46
|
+
if branch:
|
|
47
|
+
cmd.extend(["-b", branch])
|
|
48
|
+
if single_branch:
|
|
49
|
+
cmd.append("--single-branch")
|
|
50
|
+
cmd.extend([url, dest])
|
|
51
|
+
result = run_and_log(cmd)
|
|
52
|
+
return result.returncode == 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def init(path: str) -> None:
|
|
56
|
+
"""
|
|
57
|
+
Usage:
|
|
58
|
+
|
|
59
|
+
- Initializes a new git repository
|
|
60
|
+
|
|
61
|
+
Requires:
|
|
62
|
+
|
|
63
|
+
- ``path``:
|
|
64
|
+
- Type: str
|
|
65
|
+
- What: The directory to initialize
|
|
66
|
+
"""
|
|
67
|
+
run_and_log(["git", "init"], cwd=path)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def add(path: str, files: list[str] | None = None) -> None:
|
|
71
|
+
"""
|
|
72
|
+
Usage:
|
|
73
|
+
|
|
74
|
+
- Stages files in the git repository
|
|
75
|
+
|
|
76
|
+
Requires:
|
|
77
|
+
|
|
78
|
+
- ``path``:
|
|
79
|
+
- Type: str
|
|
80
|
+
- What: The repository directory
|
|
81
|
+
|
|
82
|
+
Optional:
|
|
83
|
+
|
|
84
|
+
- ``files``:
|
|
85
|
+
- Type: list[str] | None
|
|
86
|
+
- What: Specific files to stage
|
|
87
|
+
- Default: None (stages all files)
|
|
88
|
+
"""
|
|
89
|
+
cmd = ["git", "add"]
|
|
90
|
+
cmd.extend(files or ["."])
|
|
91
|
+
run_and_log(cmd, cwd=path)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def commit(path: str, message: str) -> None:
|
|
95
|
+
"""
|
|
96
|
+
Usage:
|
|
97
|
+
|
|
98
|
+
- Creates a git commit
|
|
99
|
+
|
|
100
|
+
Requires:
|
|
101
|
+
|
|
102
|
+
- ``path``:
|
|
103
|
+
- Type: str
|
|
104
|
+
- What: The repository directory
|
|
105
|
+
|
|
106
|
+
- ``message``:
|
|
107
|
+
- Type: str
|
|
108
|
+
- What: The commit message
|
|
109
|
+
"""
|
|
110
|
+
run_and_log(["git", "commit", "-m", message], cwd=path)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def branch_rename(path: str, name: str) -> None:
|
|
114
|
+
"""
|
|
115
|
+
Usage:
|
|
116
|
+
|
|
117
|
+
- Renames the current branch
|
|
118
|
+
|
|
119
|
+
Requires:
|
|
120
|
+
|
|
121
|
+
- ``path``:
|
|
122
|
+
- Type: str
|
|
123
|
+
- What: The repository directory
|
|
124
|
+
|
|
125
|
+
- ``name``:
|
|
126
|
+
- Type: str
|
|
127
|
+
- What: The new branch name
|
|
128
|
+
"""
|
|
129
|
+
run_and_log(["git", "branch", "-M", name], cwd=path)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def fetch(path: str) -> None:
|
|
133
|
+
"""
|
|
134
|
+
Usage:
|
|
135
|
+
|
|
136
|
+
- Fetches from the remote
|
|
137
|
+
|
|
138
|
+
Requires:
|
|
139
|
+
|
|
140
|
+
- ``path``:
|
|
141
|
+
- Type: str
|
|
142
|
+
- What: The repository directory
|
|
143
|
+
"""
|
|
144
|
+
run_and_log(["git", "fetch"], cwd=path)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def checkout(path: str, ref: str) -> None:
|
|
148
|
+
"""
|
|
149
|
+
Usage:
|
|
150
|
+
|
|
151
|
+
- Checks out a branch or tag
|
|
152
|
+
|
|
153
|
+
Requires:
|
|
154
|
+
|
|
155
|
+
- ``path``:
|
|
156
|
+
- Type: str
|
|
157
|
+
- What: The repository directory
|
|
158
|
+
|
|
159
|
+
- ``ref``:
|
|
160
|
+
- Type: str
|
|
161
|
+
- What: The branch, tag, or commit to check out
|
|
162
|
+
"""
|
|
163
|
+
run_and_log(["git", "checkout", ref], cwd=path)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def pull(path: str) -> None:
|
|
167
|
+
"""
|
|
168
|
+
Usage:
|
|
169
|
+
|
|
170
|
+
- Pulls from the remote
|
|
171
|
+
|
|
172
|
+
Requires:
|
|
173
|
+
|
|
174
|
+
- ``path``:
|
|
175
|
+
- Type: str
|
|
176
|
+
- What: The repository directory
|
|
177
|
+
"""
|
|
178
|
+
run_and_log(["git", "pull"], cwd=path)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def ls_remote_tags(url: str) -> list[str]:
|
|
182
|
+
"""
|
|
183
|
+
Usage:
|
|
184
|
+
|
|
185
|
+
- Lists remote tags for a git repository
|
|
186
|
+
|
|
187
|
+
Requires:
|
|
188
|
+
|
|
189
|
+
- ``url``:
|
|
190
|
+
- Type: str
|
|
191
|
+
- What: The repository URL
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
|
|
195
|
+
- ``tags``:
|
|
196
|
+
- Type: list[str]
|
|
197
|
+
- What: A sorted list of tag names
|
|
198
|
+
"""
|
|
199
|
+
result = run(["git", "ls-remote", "--tags", url])
|
|
200
|
+
tags: list[str] = []
|
|
201
|
+
if result.returncode != 0 or not result.stdout:
|
|
202
|
+
return tags
|
|
203
|
+
for line in result.stdout.strip().splitlines():
|
|
204
|
+
parts = line.split("refs/tags/")
|
|
205
|
+
if len(parts) == 2:
|
|
206
|
+
tag = parts[1].rstrip("^{}")
|
|
207
|
+
if tag and tag not in tags and not tag.endswith("^{}"):
|
|
208
|
+
tags.append(tag)
|
|
209
|
+
return tags
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def ls_remote_heads(url: str) -> list[str]:
|
|
213
|
+
"""
|
|
214
|
+
Usage:
|
|
215
|
+
|
|
216
|
+
- Lists remote branch heads for a git repository
|
|
217
|
+
|
|
218
|
+
Requires:
|
|
219
|
+
|
|
220
|
+
- ``url``:
|
|
221
|
+
- Type: str
|
|
222
|
+
- What: The repository URL
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
|
|
226
|
+
- ``branches``:
|
|
227
|
+
- Type: list[str]
|
|
228
|
+
- What: A sorted list of branch names
|
|
229
|
+
"""
|
|
230
|
+
result = run(["git", "ls-remote", "--heads", url])
|
|
231
|
+
branches: list[str] = []
|
|
232
|
+
if result.returncode != 0 or not result.stdout:
|
|
233
|
+
return branches
|
|
234
|
+
for line in result.stdout.strip().splitlines():
|
|
235
|
+
parts = line.split("refs/heads/")
|
|
236
|
+
if len(parts) == 2:
|
|
237
|
+
branch = parts[1].strip()
|
|
238
|
+
if branch:
|
|
239
|
+
branches.append(branch)
|
|
240
|
+
return branches
|
cave_cli/utils/logger.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CaveLogger:
|
|
5
|
+
"""
|
|
6
|
+
Usage:
|
|
7
|
+
|
|
8
|
+
- Multi-level logger matching the bash CLI's DEBUG/INFO/WARN/ERROR/SILENT behavior
|
|
9
|
+
|
|
10
|
+
Notes:
|
|
11
|
+
|
|
12
|
+
- Output goes to stderr in the format ``LEVEL: message``
|
|
13
|
+
- Module-level singleton ``logger`` is used throughout the package
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
LEVELS: dict[str, int] = {
|
|
17
|
+
"DEBUG": 0,
|
|
18
|
+
"INFO": 1,
|
|
19
|
+
"WARN": 2,
|
|
20
|
+
"ERROR": 3,
|
|
21
|
+
"SILENT": 4,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
def __init__(self, level: str = "INFO") -> None:
|
|
25
|
+
self.set_level(level)
|
|
26
|
+
|
|
27
|
+
def set_level(self, level: str) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Usage:
|
|
30
|
+
|
|
31
|
+
- Sets the minimum log level for output
|
|
32
|
+
|
|
33
|
+
Requires:
|
|
34
|
+
|
|
35
|
+
- ``level``:
|
|
36
|
+
- Type: str
|
|
37
|
+
- What: One of DEBUG, INFO, WARN, ERROR, SILENT
|
|
38
|
+
"""
|
|
39
|
+
level = level.upper()
|
|
40
|
+
if level not in self.LEVELS:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"Invalid log level {level!r}. "
|
|
43
|
+
f"Must be one of: {list(self.LEVELS.keys())}"
|
|
44
|
+
)
|
|
45
|
+
self._level = self.LEVELS[level]
|
|
46
|
+
|
|
47
|
+
def log(self, message: str, level: str) -> None:
|
|
48
|
+
if self.LEVELS.get(level, 0) >= self._level:
|
|
49
|
+
sys.stderr.write(f"{level}: {message}\n")
|
|
50
|
+
sys.stderr.flush()
|
|
51
|
+
|
|
52
|
+
def debug(self, message: str) -> None:
|
|
53
|
+
self.log(message, "DEBUG")
|
|
54
|
+
|
|
55
|
+
def info(self, message: str) -> None:
|
|
56
|
+
self.log(message, "INFO")
|
|
57
|
+
|
|
58
|
+
def warn(self, message: str) -> None:
|
|
59
|
+
self.log(message, "WARN")
|
|
60
|
+
|
|
61
|
+
def error(self, message: str) -> None:
|
|
62
|
+
self.log(message, "ERROR")
|
|
63
|
+
|
|
64
|
+
def header(self, text: str) -> None:
|
|
65
|
+
"""
|
|
66
|
+
Usage:
|
|
67
|
+
|
|
68
|
+
- Prints a header block with separator lines at INFO level
|
|
69
|
+
"""
|
|
70
|
+
from cave_cli.utils.constants import CHAR_LINE
|
|
71
|
+
|
|
72
|
+
self.info(CHAR_LINE)
|
|
73
|
+
self.info(text)
|
|
74
|
+
self.info(CHAR_LINE)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
logger = CaveLogger()
|
cave_cli/utils/net.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import socket
|
|
3
|
+
|
|
4
|
+
from cave_cli.utils.constants import IP_PORT_RE
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def is_port_available(port: int) -> bool:
|
|
8
|
+
"""
|
|
9
|
+
Usage:
|
|
10
|
+
|
|
11
|
+
- Checks if a local TCP port is available for binding
|
|
12
|
+
|
|
13
|
+
Requires:
|
|
14
|
+
|
|
15
|
+
- ``port``:
|
|
16
|
+
- Type: int
|
|
17
|
+
- What: The port number to check
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
|
|
21
|
+
- ``available``:
|
|
22
|
+
- Type: bool
|
|
23
|
+
- What: True if the port is available, False if in use
|
|
24
|
+
"""
|
|
25
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
26
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
27
|
+
try:
|
|
28
|
+
s.bind(("0.0.0.0", port))
|
|
29
|
+
return True
|
|
30
|
+
except OSError:
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def find_open_port(start: int = 8000) -> int:
|
|
35
|
+
"""
|
|
36
|
+
Usage:
|
|
37
|
+
|
|
38
|
+
- Finds the next available TCP port starting from the given port
|
|
39
|
+
|
|
40
|
+
Optional:
|
|
41
|
+
|
|
42
|
+
- ``start``:
|
|
43
|
+
- Type: int
|
|
44
|
+
- What: The port number to start searching from
|
|
45
|
+
- Default: 8000
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
|
|
49
|
+
- ``port``:
|
|
50
|
+
- Type: int
|
|
51
|
+
- What: The first available port found
|
|
52
|
+
"""
|
|
53
|
+
port = start
|
|
54
|
+
while port < 65535:
|
|
55
|
+
if is_port_available(port):
|
|
56
|
+
return port
|
|
57
|
+
port += 1
|
|
58
|
+
raise RuntimeError("No open ports found")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_ip_port(addr: str) -> tuple[str, int] | None:
|
|
62
|
+
"""
|
|
63
|
+
Usage:
|
|
64
|
+
|
|
65
|
+
- Parses and validates an IP:port address string
|
|
66
|
+
|
|
67
|
+
Requires:
|
|
68
|
+
|
|
69
|
+
- ``addr``:
|
|
70
|
+
- Type: str
|
|
71
|
+
- What: The address string in IP:port format
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
|
|
75
|
+
- ``result``:
|
|
76
|
+
- Type: tuple[str, int] | None
|
|
77
|
+
- What: A tuple of (ip, port) if valid, or None if invalid
|
|
78
|
+
"""
|
|
79
|
+
if not IP_PORT_RE.match(addr):
|
|
80
|
+
return None
|
|
81
|
+
ip_match = re.search(r"([0-9]{1,3}\.)+[0-9]{1,3}", addr)
|
|
82
|
+
port_match = re.search(r"(?<=:)\d{4,}", addr)
|
|
83
|
+
if ip_match and port_match:
|
|
84
|
+
return ip_match.group(0), int(port_match.group(0))
|
|
85
|
+
return None
|