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/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
@@ -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